Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9bf7215125
|
|||
|
5f5ff19390
|
|||
|
30d35a2368
|
|||
|
64392c3935
|
|||
|
a1304e19ad
|
|||
|
ab94617f06
|
|||
|
5d6646d8ac
|
|||
|
919d6c98ee
|
|||
|
d17e11f2bc
|
|||
|
17c5fdb8a8
|
|||
|
74dffaeae4
|
|||
|
7fc29fccb8
|
|||
|
00758d6a50
|
|||
|
508b04af19
|
|||
|
6d21ffc4c7
|
|||
|
9490e55f89
|
|||
|
0b9dd702e1
|
|||
|
af62120c8d
|
|||
|
dd2ebe5888
|
|||
|
835caf6a71
|
|||
|
231fa483e7
|
|||
|
32eb882a98
|
|||
|
0179363684
|
|||
|
ad5c8d3bb1
|
@@ -22,6 +22,9 @@ init:
|
|||||||
pnpm install
|
pnpm install
|
||||||
$(MAKE) loadplatforms
|
$(MAKE) loadplatforms
|
||||||
|
|
||||||
|
server:
|
||||||
|
uv run python -Wa manage.py runserver
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@pnpm concurrently \
|
@pnpm concurrently \
|
||||||
--names "Django,Tailwind" \
|
--names "Django,Tailwind" \
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ Split into core / primitives / domain / filters submodules; this package
|
|||||||
re-exports the public API so ``from common.components import X`` keeps working.
|
re-exports the public API so ``from common.components import X`` keeps working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from common.utils import truncate
|
|
||||||
|
|
||||||
from common.components.core import (
|
from common.components.core import (
|
||||||
Component,
|
Component,
|
||||||
HTMLAttribute,
|
HTMLAttribute,
|
||||||
@@ -13,41 +11,6 @@ from common.components.core import (
|
|||||||
_render_element,
|
_render_element,
|
||||||
randomid,
|
randomid,
|
||||||
)
|
)
|
||||||
from common.components.primitives import (
|
|
||||||
A,
|
|
||||||
AddForm,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
CsrfInput,
|
|
||||||
Div,
|
|
||||||
ExternalScript,
|
|
||||||
H1,
|
|
||||||
Icon,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModuleScript,
|
|
||||||
Pill,
|
|
||||||
Popover,
|
|
||||||
PopoverTruncated,
|
|
||||||
SearchField,
|
|
||||||
SimpleTable,
|
|
||||||
Span,
|
|
||||||
Label,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
TableTd,
|
|
||||||
Template,
|
|
||||||
YearPicker,
|
|
||||||
paginated_table_content,
|
|
||||||
)
|
|
||||||
from common.components.search_select import (
|
|
||||||
DEFAULT_PREFETCH,
|
|
||||||
FilterSelect,
|
|
||||||
LabeledOption,
|
|
||||||
SearchSelect,
|
|
||||||
SearchSelectOption,
|
|
||||||
searchselect_selected,
|
|
||||||
)
|
|
||||||
from common.components.domain import (
|
from common.components.domain import (
|
||||||
GameLink,
|
GameLink,
|
||||||
GameStatus,
|
GameStatus,
|
||||||
@@ -60,13 +23,56 @@ from common.components.domain import (
|
|||||||
_resolve_name_with_icon,
|
_resolve_name_with_icon,
|
||||||
)
|
)
|
||||||
from common.components.filters import (
|
from common.components.filters import (
|
||||||
FilterBar,
|
|
||||||
PurchaseFilterBar,
|
|
||||||
SessionFilterBar,
|
|
||||||
DeviceFilterBar,
|
DeviceFilterBar,
|
||||||
|
FilterBar,
|
||||||
PlatformFilterBar,
|
PlatformFilterBar,
|
||||||
PlayEventFilterBar,
|
PlayEventFilterBar,
|
||||||
|
PurchaseFilterBar,
|
||||||
|
SessionFilterBar,
|
||||||
|
StringFilter,
|
||||||
)
|
)
|
||||||
|
from common.components.primitives import (
|
||||||
|
H1,
|
||||||
|
A,
|
||||||
|
AddForm,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Checkbox,
|
||||||
|
CsrfInput,
|
||||||
|
Div,
|
||||||
|
ExternalScript,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Li,
|
||||||
|
Modal,
|
||||||
|
ModuleScript,
|
||||||
|
Pill,
|
||||||
|
Popover,
|
||||||
|
PopoverTruncated,
|
||||||
|
Radio,
|
||||||
|
SearchField,
|
||||||
|
SimpleTable,
|
||||||
|
Span,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableTd,
|
||||||
|
Td,
|
||||||
|
Template,
|
||||||
|
Tr,
|
||||||
|
Ul,
|
||||||
|
YearPicker,
|
||||||
|
paginated_table_content,
|
||||||
|
)
|
||||||
|
from common.components.search_select import (
|
||||||
|
DEFAULT_PREFETCH,
|
||||||
|
FilterSelect,
|
||||||
|
LabeledOption,
|
||||||
|
SearchSelect,
|
||||||
|
SearchSelectOption,
|
||||||
|
searchselect_selected,
|
||||||
|
)
|
||||||
|
from common.utils import truncate
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"truncate",
|
"truncate",
|
||||||
@@ -79,6 +85,7 @@ __all__ = [
|
|||||||
"AddForm",
|
"AddForm",
|
||||||
"Button",
|
"Button",
|
||||||
"ButtonGroup",
|
"ButtonGroup",
|
||||||
|
"Checkbox",
|
||||||
"CsrfInput",
|
"CsrfInput",
|
||||||
"Div",
|
"Div",
|
||||||
"ExternalScript",
|
"ExternalScript",
|
||||||
@@ -90,6 +97,7 @@ __all__ = [
|
|||||||
"Pill",
|
"Pill",
|
||||||
"Popover",
|
"Popover",
|
||||||
"PopoverTruncated",
|
"PopoverTruncated",
|
||||||
|
"Radio",
|
||||||
"SearchField",
|
"SearchField",
|
||||||
"DEFAULT_PREFETCH",
|
"DEFAULT_PREFETCH",
|
||||||
"FilterSelect",
|
"FilterSelect",
|
||||||
@@ -121,4 +129,5 @@ __all__ = [
|
|||||||
"DeviceFilterBar",
|
"DeviceFilterBar",
|
||||||
"PlatformFilterBar",
|
"PlatformFilterBar",
|
||||||
"PlayEventFilterBar",
|
"PlayEventFilterBar",
|
||||||
|
"StringFilter",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.template.defaultfilters import floatformat
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components.core import Component, HTMLTag
|
from common.components.core import HTMLTag
|
||||||
from common.components.primitives import (
|
from common.components.primitives import (
|
||||||
A,
|
A,
|
||||||
Div,
|
Div,
|
||||||
@@ -33,10 +33,9 @@ def GameLink(
|
|||||||
return Span(
|
return Span(
|
||||||
attributes=[("class", "truncate-container")],
|
attributes=[("class", "truncate-container")],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
A(
|
||||||
tag_name="a",
|
href=link,
|
||||||
attributes=[
|
attributes=[
|
||||||
("href", link),
|
|
||||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||||
],
|
],
|
||||||
children=display if isinstance(display, list) else [display],
|
children=display if isinstance(display, list) else [display],
|
||||||
|
|||||||
+544
-143
@@ -6,8 +6,12 @@ from django.db import models
|
|||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components.core import Component
|
from common.components.core import Component
|
||||||
from common.components.primitives import Label, Span
|
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
|
||||||
from common.components.search_select import DEFAULT_PREFETCH, FilterSelect, LabeledOption
|
from common.components.search_select import (
|
||||||
|
DEFAULT_PREFETCH,
|
||||||
|
FilterSelect,
|
||||||
|
LabeledOption,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FilterChoice(NamedTuple):
|
class FilterChoice(NamedTuple):
|
||||||
@@ -39,6 +43,12 @@ _FILTER_CHECKBOX_CLASS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_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"
|
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +96,24 @@ def _parse_bool(existing: dict, key: str) -> bool:
|
|||||||
return bool(field.get("value", 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 ────────────────────────────────────────────────────
|
# ── FilterSelect adapters ────────────────────────────────────────────────────
|
||||||
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
|
# 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
|
# option set; model-backed fields fetch from a search endpoint on demand, with
|
||||||
@@ -204,14 +232,21 @@ def _filter_mins_to_hrs(val) -> str:
|
|||||||
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
|
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
|
||||||
|
|
||||||
|
|
||||||
def _filter_field(label: str, widget) -> SafeText:
|
def _filter_field(label: str, widget, for_widget: str = None) -> SafeText:
|
||||||
"""A labelled filter field: <div><label>…</label>{widget}</div>."""
|
"""A labelled filter field: <div><label>…</label>{widget}</div>.
|
||||||
return Component(
|
TODO: Use widget.attributes.get("id", "") to get the widget's ID
|
||||||
tag_name="div",
|
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")],
|
attributes=[("class", "flex flex-col gap-1")],
|
||||||
children=[
|
children=[
|
||||||
Label(
|
Label(
|
||||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
attributes=[
|
||||||
|
("class", _FILTER_LABEL_CLASS),
|
||||||
|
("for", for_widget),
|
||||||
|
],
|
||||||
children=[label],
|
children=[label],
|
||||||
),
|
),
|
||||||
widget,
|
widget,
|
||||||
@@ -220,20 +255,28 @@ def _filter_field(label: str, widget) -> SafeText:
|
|||||||
|
|
||||||
|
|
||||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||||
return Label(
|
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
|
||||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
|
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=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="input",
|
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||||
attributes=[
|
children=[label],
|
||||||
("type", "checkbox"),
|
),
|
||||||
("name", name),
|
Div(
|
||||||
("value", "1"),
|
attributes=[("class", "flex items-center gap-4 h-9")],
|
||||||
*([("checked", "true")] if checked else []),
|
children=[
|
||||||
("class", _FILTER_CHECKBOX_CLASS),
|
Radio(name=name, label="True", checked=value is True, value="true"),
|
||||||
|
Radio(
|
||||||
|
name=name, label="False", checked=value is False, value="false"
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
label,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -283,24 +326,22 @@ def RangeSlider(
|
|||||||
point_mode = bool(min_value and max_value and min_value == max_value)
|
point_mode = bool(min_value and max_value and min_value == max_value)
|
||||||
initial_mode = "point" if point_mode else "range"
|
initial_mode = "point" if point_mode else "range"
|
||||||
|
|
||||||
return Component(
|
return Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "range-slider-block mb-4")],
|
attributes=[("class", "range-slider-block mb-4")],
|
||||||
children=[
|
children=[
|
||||||
# ── Label row ──
|
# ── Label row ──
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||||
children=[
|
children=[
|
||||||
Label(
|
# TODO: This should be done outside the RangeSlider component, but the current Component function doesn't allow getting the id
|
||||||
attributes=[
|
# Label(
|
||||||
("class", _FILTER_LABEL_CLASS),
|
# attributes=[
|
||||||
("for", min_input_id),
|
# ("class", _FILTER_LABEL_CLASS),
|
||||||
],
|
# ("for", min_input_id),
|
||||||
children=[label],
|
# ],
|
||||||
),
|
# children=[label],
|
||||||
Component(
|
# ),
|
||||||
tag_name="input",
|
Input(
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "number"),
|
("type", "number"),
|
||||||
("name", min_input_id),
|
("name", min_input_id),
|
||||||
@@ -324,8 +365,7 @@ def RangeSlider(
|
|||||||
],
|
],
|
||||||
children=["–"],
|
children=["–"],
|
||||||
),
|
),
|
||||||
Component(
|
Input(
|
||||||
tag_name="input",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "number"),
|
("type", "number"),
|
||||||
("name", max_input_id),
|
("name", max_input_id),
|
||||||
@@ -379,8 +419,7 @@ def RangeSlider(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
# ── Slider row ──
|
# ── Slider row ──
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "range-slider relative h-10 select-none mt-1"),
|
("class", "range-slider relative h-10 select-none mt-1"),
|
||||||
("data-mode", initial_mode),
|
("data-mode", initial_mode),
|
||||||
@@ -389,8 +428,7 @@ def RangeSlider(
|
|||||||
("data-step", str(step)),
|
("data-step", str(step)),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -399,8 +437,7 @@ def RangeSlider(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -411,8 +448,7 @@ def RangeSlider(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
# Min handle (hidden in point mode via JS)
|
# Min handle (hidden in point mode via JS)
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -429,8 +465,7 @@ def RangeSlider(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
# Max handle
|
# Max handle
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -449,6 +484,69 @@ def RangeSlider(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_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 ``<input type="date">`` 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_FORM_ID = "filter-bar-form"
|
||||||
|
|
||||||
|
|
||||||
@@ -480,8 +578,7 @@ def _filter_collapse_button() -> SafeText:
|
|||||||
|
|
||||||
|
|
||||||
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||||
return Component(
|
return Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "flex gap-3 items-center")],
|
attributes=[("class", "flex gap-3 items-center")],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Component(
|
||||||
@@ -521,8 +618,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
|||||||
("id", "save-preset-area"),
|
("id", "save-preset-area"),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Input(
|
||||||
tag_name="input",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "text"),
|
("type", "text"),
|
||||||
("id", "preset-name-input"),
|
("id", "preset-name-input"),
|
||||||
@@ -572,8 +668,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("id", "preset-dropdown"),
|
("id", "preset-dropdown"),
|
||||||
("class", "relative"),
|
("class", "relative"),
|
||||||
@@ -594,13 +689,11 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
|
|||||||
"""Shared collapsible filter-bar chrome. `fields` is the per-entity body
|
"""Shared collapsible filter-bar chrome. `fields` is the per-entity body
|
||||||
(grids, sliders, checkboxes); the shell adds the collapse toggle, the form,
|
(grids, sliders, checkboxes); the shell adds the collapse toggle, the form,
|
||||||
the hidden filter-json input and the Apply/Clear/preset action row."""
|
the hidden filter-json input and the Apply/Clear/preset action row."""
|
||||||
return Component(
|
return Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||||
children=[
|
children=[
|
||||||
_filter_collapse_button(),
|
_filter_collapse_button(),
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("id", "filter-bar-body"),
|
("id", "filter-bar-body"),
|
||||||
(
|
(
|
||||||
@@ -617,8 +710,7 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
|
|||||||
("onsubmit", "return applyFilterBar(event)"),
|
("onsubmit", "return applyFilterBar(event)"),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Input(
|
||||||
tag_name="input",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "hidden"),
|
("type", "hidden"),
|
||||||
("id", _FILTER_INPUT_ID),
|
("id", _FILTER_INPUT_ID),
|
||||||
@@ -645,7 +737,7 @@ def FilterBar(
|
|||||||
preset_save_url: str = "",
|
preset_save_url: str = "",
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Game list."""
|
"""Collapsible filter bar for the Game list."""
|
||||||
from games.models import Game
|
from games.models import Game, Purchase
|
||||||
|
|
||||||
if status_options is None:
|
if status_options is None:
|
||||||
status_options = [(s.value, s.label) for s in Game.Status]
|
status_options = [(s.value, s.label) for s in Game.Status]
|
||||||
@@ -653,9 +745,20 @@ def FilterBar(
|
|||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
status_choice = _filter_get_choice(existing, "status")
|
status_choice = _filter_get_choice(existing, "status")
|
||||||
platform_choice = _filter_get_choice(existing, "platform")
|
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")
|
year_min, year_max = _parse_range(existing, "year_released")
|
||||||
mastered_value = _parse_bool(existing, "mastered")
|
original_year_min, original_year_max = _parse_range(
|
||||||
|
existing, "original_year_released"
|
||||||
|
)
|
||||||
|
mastered_value = _parse_bool_nullable(existing, "mastered")
|
||||||
playtime = existing.get("playtime_minutes", {})
|
playtime = existing.get("playtime_minutes", {})
|
||||||
if isinstance(playtime, dict):
|
if isinstance(playtime, dict):
|
||||||
playtime_min = _filter_mins_to_hrs(playtime.get("value", ""))
|
playtime_min = _filter_mins_to_hrs(playtime.get("value", ""))
|
||||||
@@ -664,10 +767,17 @@ def FilterBar(
|
|||||||
playtime_min = ""
|
playtime_min = ""
|
||||||
playtime_max = ""
|
playtime_max = ""
|
||||||
|
|
||||||
has_purchases_value = _parse_bool(existing, "has_purchases")
|
|
||||||
has_playevents_value = _parse_bool(existing, "has_playevents")
|
|
||||||
session_count_min, session_count_max = _parse_range(existing, "session_count")
|
session_count_min, session_count_max = _parse_range(existing, "session_count")
|
||||||
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
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:
|
try:
|
||||||
year_aggregate = Game.objects.aggregate(
|
year_aggregate = Game.objects.aggregate(
|
||||||
@@ -675,21 +785,42 @@ def FilterBar(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
year_aggregate = {}
|
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:
|
try:
|
||||||
playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime"))
|
playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime"))
|
||||||
except Exception:
|
except Exception:
|
||||||
playtime_aggregate = {}
|
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_min = max(int(year_aggregate.get("year_min") or 1970), 1970)
|
||||||
year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030)
|
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 = (
|
playtime_range_max = (
|
||||||
int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600)
|
int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600)
|
||||||
if playtime_aggregate.get("playtime_max")
|
if playtime_aggregate.get("playtime_max")
|
||||||
else 200
|
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 = [
|
fields = [
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -710,8 +841,53 @@ def FilterBar(
|
|||||||
nullable=Game._meta.get_field("platform").null,
|
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(
|
RangeSlider(
|
||||||
label="Year",
|
label="Year",
|
||||||
input_name_prefix="filter-year",
|
input_name_prefix="filter-year",
|
||||||
@@ -722,17 +898,24 @@ def FilterBar(
|
|||||||
min_placeholder="e.g. 2020",
|
min_placeholder="e.g. 2020",
|
||||||
max_placeholder="e.g. 2024",
|
max_placeholder="e.g. 2024",
|
||||||
),
|
),
|
||||||
Component(
|
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
|
||||||
children=[
|
|
||||||
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
|
|
||||||
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
|
|
||||||
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Original Year",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Playtime",
|
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",
|
input_name_prefix="filter-playtime",
|
||||||
min_value=playtime_min,
|
min_value=playtime_min,
|
||||||
max_value=playtime_max,
|
max_value=playtime_max,
|
||||||
@@ -742,6 +925,51 @@ def FilterBar(
|
|||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 100",
|
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(
|
RangeSlider(
|
||||||
label="Session Count",
|
label="Session Count",
|
||||||
input_name_prefix="filter-session-count",
|
input_name_prefix="filter-session-count",
|
||||||
@@ -753,6 +981,9 @@ def FilterBar(
|
|||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 50",
|
max_placeholder="e.g. 50",
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Average Session Duration (mins)",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Average Session Duration (mins)",
|
label="Average Session Duration (mins)",
|
||||||
input_name_prefix="filter-session-average",
|
input_name_prefix="filter-session-average",
|
||||||
@@ -764,6 +995,78 @@ def FilterBar(
|
|||||||
min_placeholder="e.g. 10",
|
min_placeholder="e.g. 10",
|
||||||
max_placeholder="e.g. 120",
|
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)
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
@@ -784,12 +1087,14 @@ def SessionFilterBar(
|
|||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
game_choice = _filter_get_choice(existing, "game")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
device_choice = _filter_get_choice(existing, "device")
|
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_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_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")
|
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
||||||
emulated_value = _parse_bool(existing, "emulated")
|
emulated_value = _parse_bool_nullable(existing, "emulated")
|
||||||
is_active_value = _parse_bool(existing, "is_active")
|
is_active_value = _parse_bool_nullable(existing, "is_active")
|
||||||
try:
|
try:
|
||||||
duration_aggregate = Session.objects.aggregate(
|
duration_aggregate = Session.objects.aggregate(
|
||||||
duration_max=models.Max("duration_total")
|
duration_max=models.Max("duration_total")
|
||||||
@@ -804,8 +1109,7 @@ def SessionFilterBar(
|
|||||||
duration_range_max = 200
|
duration_range_max = 200
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -826,6 +1130,15 @@ def SessionFilterBar(
|
|||||||
nullable=Session._meta.get_field("device").null,
|
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(
|
RangeSlider(
|
||||||
@@ -861,12 +1174,11 @@ def SessionFilterBar(
|
|||||||
min_placeholder="e.g. 30",
|
min_placeholder="e.g. 30",
|
||||||
max_placeholder="e.g. 180",
|
max_placeholder="e.g. 180",
|
||||||
),
|
),
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
attributes=[("class", "flex gap-6 mb-4")],
|
||||||
attributes=[("class", "flex gap-4 mb-4")],
|
|
||||||
children=[
|
children=[
|
||||||
_filter_checkbox("filter-emulated", "Emulated", emulated_value),
|
_filter_boolean_radio("filter-emulated", "Emulated", emulated_value),
|
||||||
_filter_checkbox("filter-active", "Active", is_active_value),
|
_filter_boolean_radio("filter-active", "Active", is_active_value),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -887,11 +1199,19 @@ def PurchaseFilterBar(
|
|||||||
type_choice = _filter_get_choice(existing, "type")
|
type_choice = _filter_get_choice(existing, "type")
|
||||||
ownership_choice = _filter_get_choice(existing, "ownership_type")
|
ownership_choice = _filter_get_choice(existing, "ownership_type")
|
||||||
price_min, price_max = _parse_range(existing, "price")
|
price_min, price_max = _parse_range(existing, "price")
|
||||||
is_refunded_value = _parse_bool(existing, "is_refunded")
|
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
|
||||||
infinite_value = _parse_bool(existing, "infinite")
|
infinite_value = _parse_bool_nullable(existing, "infinite")
|
||||||
needs_price_update_value = _parse_bool(existing, "needs_price_update")
|
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
|
||||||
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
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_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:
|
try:
|
||||||
price_aggregate = Purchase.objects.aggregate(
|
price_aggregate = Purchase.objects.aggregate(
|
||||||
@@ -913,8 +1233,7 @@ def PurchaseFilterBar(
|
|||||||
num_range_min, num_range_max = 0, 10
|
num_range_min, num_range_max = 0, 10
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -959,49 +1278,49 @@ def PurchaseFilterBar(
|
|||||||
).has_default(),
|
).has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
Div(
|
||||||
),
|
|
||||||
Component(
|
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
|
||||||
children=[
|
|
||||||
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
|
|
||||||
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
|
|
||||||
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Component(
|
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Original Currency",
|
"Original Currency",
|
||||||
Component(
|
StringFilter(
|
||||||
tag_name="input",
|
input_name_prefix="filter-price_currency",
|
||||||
attributes=[
|
value=price_currency_value,
|
||||||
("type", "text"),
|
modifier=price_currency_modifier,
|
||||||
("name", "filter-price_currency"),
|
placeholder="e.g. USD, EUR",
|
||||||
("value", price_currency_value),
|
|
||||||
("placeholder", "e.g. USD, EUR"),
|
|
||||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Converted Currency",
|
"Converted Currency",
|
||||||
Component(
|
StringFilter(
|
||||||
tag_name="input",
|
input_name_prefix="filter-converted_currency",
|
||||||
attributes=[
|
value=converted_currency_value,
|
||||||
("type", "text"),
|
modifier=converted_currency_modifier,
|
||||||
("name", "filter-converted_currency"),
|
placeholder="e.g. USD, EUR",
|
||||||
("value", converted_currency_value),
|
|
||||||
("placeholder", "e.g. USD, EUR"),
|
|
||||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
_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(
|
RangeSlider(
|
||||||
label="Price",
|
label="Price",
|
||||||
input_name_prefix="filter-price",
|
input_name_prefix="filter-price",
|
||||||
@@ -1012,6 +1331,9 @@ def PurchaseFilterBar(
|
|||||||
min_placeholder="0.00",
|
min_placeholder="0.00",
|
||||||
max_placeholder="100.00",
|
max_placeholder="100.00",
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Games in purchase",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Games in purchase",
|
label="Games in purchase",
|
||||||
input_name_prefix="filter-num-purchases",
|
input_name_prefix="filter-num-purchases",
|
||||||
@@ -1023,13 +1345,30 @@ def PurchaseFilterBar(
|
|||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 5",
|
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)
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
def DeviceFilterBar(
|
def DeviceFilterBar(filter_json="", preset_list_url="", preset_save_url="") -> SafeText:
|
||||||
filter_json="", preset_list_url="", preset_save_url=""
|
|
||||||
) -> SafeText:
|
|
||||||
"""Collapsible filter bar for the Device list."""
|
"""Collapsible filter bar for the Device list."""
|
||||||
from games.models import Device
|
from games.models import Device
|
||||||
|
|
||||||
@@ -1038,8 +1377,7 @@ def DeviceFilterBar(
|
|||||||
type_choice = _filter_get_choice(existing, "type")
|
type_choice = _filter_get_choice(existing, "type")
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -1064,37 +1402,30 @@ def PlatformFilterBar(
|
|||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
|
|
||||||
name_value = existing.get("name", {}).get("value", "")
|
name_value = existing.get("name", {}).get("value", "")
|
||||||
|
name_modifier = existing.get("name", {}).get("modifier", "EQUALS")
|
||||||
group_value = existing.get("group", {}).get("value", "")
|
group_value = existing.get("group", {}).get("value", "")
|
||||||
|
group_modifier = existing.get("group", {}).get("modifier", "EQUALS")
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Platform Name",
|
"Platform Name",
|
||||||
Component(
|
StringFilter(
|
||||||
tag_name="input",
|
input_name_prefix="filter-name",
|
||||||
attributes=[
|
value=name_value,
|
||||||
("type", "text"),
|
modifier=name_modifier,
|
||||||
("name", "filter-name"),
|
placeholder="e.g. Nintendo Switch",
|
||||||
("value", name_value),
|
|
||||||
("placeholder", "e.g. Nintendo Switch"),
|
|
||||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Platform Group",
|
"Platform Group",
|
||||||
Component(
|
StringFilter(
|
||||||
tag_name="input",
|
input_name_prefix="filter-group",
|
||||||
attributes=[
|
value=group_value,
|
||||||
("type", "text"),
|
modifier=group_modifier,
|
||||||
("name", "filter-group"),
|
placeholder="e.g. Nintendo",
|
||||||
("value", group_value),
|
|
||||||
("placeholder", "e.g. Nintendo"),
|
|
||||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1112,8 +1443,7 @@ def PlayEventFilterBar(
|
|||||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -1140,3 +1470,74 @@ def PlayEventFilterBar(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
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),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
+140
-23
@@ -6,10 +6,9 @@ from django.urls import reverse
|
|||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
|
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
||||||
from common.icons import get_icon
|
from common.icons import get_icon
|
||||||
from common.utils import truncate
|
from common.utils import truncate
|
||||||
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
|
||||||
|
|
||||||
|
|
||||||
_COLOR_CLASSES = {
|
_COLOR_CLASSES = {
|
||||||
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
|
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
|
||||||
@@ -57,8 +56,7 @@ def _popover_html(
|
|||||||
"dark:bg-purple-800"
|
"dark:bg-purple-800"
|
||||||
)
|
)
|
||||||
|
|
||||||
div = Component(
|
div = Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("data-popover", ""),
|
("data-popover", ""),
|
||||||
("id", id),
|
("id", id),
|
||||||
@@ -66,12 +64,11 @@ def _popover_html(
|
|||||||
("class", popover_tooltip_class),
|
("class", popover_tooltip_class),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "px-3 py-2")],
|
attributes=[("class", "px-3 py-2")],
|
||||||
children=[popover_content],
|
children=[popover_content],
|
||||||
),
|
),
|
||||||
Component(tag_name="div", attributes=[("data-popper-arrow", "")]),
|
Div(attributes=[("data-popper-arrow", "")]),
|
||||||
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
|
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
|
||||||
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
||||||
"from Python component -->"
|
"from Python component -->"
|
||||||
@@ -323,8 +320,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return Component(
|
return Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
|
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
|
||||||
children=children,
|
children=children,
|
||||||
)
|
)
|
||||||
@@ -339,6 +335,42 @@ def Div(
|
|||||||
return Component(tag_name="div", attributes=attributes, children=children)
|
return Component(tag_name="div", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def P(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="p", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Ul(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="ul", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Li(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="li", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Strong(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="strong", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
def Input(
|
def Input(
|
||||||
type: str = "text",
|
type: str = "text",
|
||||||
attributes: list[HTMLAttribute] | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
@@ -369,6 +401,70 @@ def Label(
|
|||||||
return Component(tag_name="label", attributes=attributes, children=children)
|
return Component(tag_name="label", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Checkbox(
|
||||||
|
name: str,
|
||||||
|
label: str | None = None,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "1",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Checkbox component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
|
||||||
|
),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
input_el = Input(type="checkbox", attributes=input_attrs)
|
||||||
|
if label is None:
|
||||||
|
return input_el
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[
|
||||||
|
("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")
|
||||||
|
],
|
||||||
|
children=[input_el, label],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Radio(
|
||||||
|
name: str,
|
||||||
|
label: str | None = None,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Radio component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
|
||||||
|
),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
input_el = Input(type="radio", attributes=input_attrs)
|
||||||
|
if label is None:
|
||||||
|
return input_el
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[
|
||||||
|
("class", "flex items-center gap-1 text-sm text-heading cursor-pointer")
|
||||||
|
],
|
||||||
|
children=[input_el, label],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def Template(
|
def Template(
|
||||||
attributes: list[HTMLAttribute] | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag | None = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
@@ -600,8 +696,7 @@ def SearchField(
|
|||||||
],
|
],
|
||||||
children=["Search"],
|
children=["Search"],
|
||||||
),
|
),
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[("class", "relative")],
|
attributes=[("class", "relative")],
|
||||||
children=[
|
children=[
|
||||||
mark_safe(
|
mark_safe(
|
||||||
@@ -612,10 +707,9 @@ def SearchField(
|
|||||||
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
|
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
|
||||||
"</svg></div>"
|
"</svg></div>"
|
||||||
),
|
),
|
||||||
Component(
|
Input(
|
||||||
tag_name="input",
|
type="search",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "search"),
|
|
||||||
("id", id),
|
("id", id),
|
||||||
("name", id),
|
("name", id),
|
||||||
("value", search_string),
|
("value", search_string),
|
||||||
@@ -687,8 +781,7 @@ def Modal(
|
|||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||||
children = children or []
|
children = children or []
|
||||||
outer = Component(
|
outer = Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("id", modal_id),
|
("id", modal_id),
|
||||||
(
|
(
|
||||||
@@ -698,8 +791,7 @@ def Modal(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Div(
|
||||||
tag_name="div",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -714,13 +806,39 @@ def Modal(
|
|||||||
return mark_safe(str(outer))
|
return mark_safe(str(outer))
|
||||||
|
|
||||||
|
|
||||||
|
def Td(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="td", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Tr(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="tr", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Th(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="th", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
def TableTd(
|
def TableTd(
|
||||||
children: list[HTMLTag] | HTMLTag | None = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Styled table cell."""
|
"""Styled table cell."""
|
||||||
children = children or []
|
children = children or []
|
||||||
return Component(
|
return Td(
|
||||||
tag_name="td",
|
|
||||||
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
||||||
children=children if isinstance(children, list) else [children],
|
children=children if isinstance(children, list) else [children],
|
||||||
)
|
)
|
||||||
@@ -765,8 +883,7 @@ def TableRow(data: dict | list | None = None) -> SafeText:
|
|||||||
for i, cell in enumerate(cells):
|
for i, cell in enumerate(cells):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
cell_elements.append(
|
cell_elements.append(
|
||||||
Component(
|
Th(
|
||||||
tag_name="th",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("scope", "row"),
|
("scope", "row"),
|
||||||
(
|
(
|
||||||
@@ -781,7 +898,7 @@ def TableRow(data: dict | list | None = None) -> SafeText:
|
|||||||
else:
|
else:
|
||||||
cell_elements.append(TableTd(children=[cell]))
|
cell_elements.append(TableTd(children=[cell]))
|
||||||
|
|
||||||
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
|
return Tr(attributes=tr_attrs, children=cell_elements)
|
||||||
|
|
||||||
|
|
||||||
def Icon(
|
def Icon(
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ def FilterSelect(
|
|||||||
items_scroll: int = 10,
|
items_scroll: int = 10,
|
||||||
placeholder: str = "Search…",
|
placeholder: str = "Search…",
|
||||||
id: str = "",
|
id: str = "",
|
||||||
|
free_text: bool = False,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||||
|
|
||||||
@@ -447,6 +448,11 @@ def FilterSelect(
|
|||||||
``included``/``excluded`` are resolved options (value + label) so pills show
|
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||||
labels even when the value rows come from ``search_url``. ``options``
|
labels even when the value rows come from ``search_url``. ``options``
|
||||||
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
||||||
|
|
||||||
|
``free_text`` turns the widget into a typed-pill input: there is no backing
|
||||||
|
option list, the JS builds an ephemeral option row from whatever the user
|
||||||
|
types so the +/− buttons (and Enter) commit the typed string itself as an
|
||||||
|
include / exclude pill.
|
||||||
"""
|
"""
|
||||||
options = [_normalize_option(option) for option in (options or [])]
|
options = [_normalize_option(option) for option in (options or [])]
|
||||||
included = [_normalize_option(option) for option in (included or [])]
|
included = [_normalize_option(option) for option in (included or [])]
|
||||||
@@ -515,7 +521,7 @@ def FilterSelect(
|
|||||||
children=[_filter_modifier_pill("", "")],
|
children=[_filter_modifier_pill("", "")],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if search_url:
|
if search_url or free_text:
|
||||||
templates.append(
|
templates.append(
|
||||||
Template(
|
Template(
|
||||||
attributes=[("data-search-select-template", "row")],
|
attributes=[("data-search-select-template", "row")],
|
||||||
@@ -536,6 +542,8 @@ def FilterSelect(
|
|||||||
("data-sync-url", "false"),
|
("data-sync-url", "false"),
|
||||||
("class", _CONTAINER_CLASS),
|
("class", _CONTAINER_CLASS),
|
||||||
]
|
]
|
||||||
|
if free_text:
|
||||||
|
container_attributes.append(("data-search-select-free-text", "true"))
|
||||||
if modifier:
|
if modifier:
|
||||||
container_attributes.append(("data-modifier", modifier))
|
container_attributes.append(("data-modifier", modifier))
|
||||||
if id:
|
if id:
|
||||||
|
|||||||
@@ -209,9 +209,6 @@ textarea:disabled {
|
|||||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||||
}
|
}
|
||||||
input[type="checkbox"] {
|
|
||||||
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
|
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# Boolean Filters Overhaul Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Overhaul the boolean criterion filters from a single checkbox (representing True/Not set) to a 2-radio-button UI representing True, False, and Unset states across all filter bars.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
1. Generalize `_filter_checkbox` into a filter-agnostic `Checkbox` component and introduce a `Radio` component in `common/components/primitives.py`.
|
||||||
|
2. Implement a nullable boolean filter JSON parsing helper `_parse_bool_nullable` and a component helper `_filter_boolean_radio` in `common/components/filters.py`.
|
||||||
|
3. Update `GameFilterBar`, `SessionFilterBar`, and `PurchaseFilterBar` in `common/components/filters.py` to leverage these new helpers.
|
||||||
|
4. Enhance `games/static/js/filter_bar.js` with deselectable radio toggling behavior and updated checked-radio state serialization.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, Django, vanilla JavaScript, HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Generalize Checkbox and Introduce Radio in Primitives
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `common/components/primitives.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test for the new Checkbox and Radio primitives**
|
||||||
|
|
||||||
|
Create a new test class `ComponentPrimitivesTest` in `tests/test_components.py` (or verify where to append) to check the output of `Checkbox` and `Radio`.
|
||||||
|
Add the following code to `tests/test_components.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from common.components.primitives import Checkbox, Radio
|
||||||
|
|
||||||
|
class ComponentPrimitivesTest(SimpleTestCase):
|
||||||
|
def test_checkbox_primitive(self):
|
||||||
|
html = Checkbox(name="test-check", label="Accept Terms", checked=True, value="yes")
|
||||||
|
self.assertIn('type="checkbox"', html)
|
||||||
|
self.assertIn('name="test-check"', html)
|
||||||
|
self.assertIn('value="yes"', html)
|
||||||
|
self.assertIn('checked="true"', html)
|
||||||
|
self.assertIn("Accept Terms", html)
|
||||||
|
|
||||||
|
def test_radio_primitive(self):
|
||||||
|
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
|
||||||
|
self.assertIn('type="radio"', html)
|
||||||
|
self.assertIn('name="test-radio"', html)
|
||||||
|
self.assertIn('value="A"', html)
|
||||||
|
self.assertNotIn('checked="true"', html)
|
||||||
|
self.assertIn("Option A", html)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||||
|
Expected output: Failures/errors due to `Checkbox` and `Radio` not being defined/imported.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement Checkbox and Radio in `common/components/primitives.py`**
|
||||||
|
|
||||||
|
Open `common/components/primitives.py` and find the other basic primitives (e.g. `Input`, `Label`). Add the following implementations and ensure they are exported / added to imports/exports:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def Checkbox(
|
||||||
|
name: str,
|
||||||
|
label: str,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "1",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Checkbox component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||||
|
children=[
|
||||||
|
Input(type="checkbox", attributes=input_attrs),
|
||||||
|
label,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Radio(
|
||||||
|
name: str,
|
||||||
|
label: str,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Radio component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||||
|
children=[
|
||||||
|
Input(type="radio", attributes=input_attrs),
|
||||||
|
label,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||||
|
Expected output: `2 passed`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add common/components/primitives.py tests/test_components.py
|
||||||
|
git commit -m "refactor: generalize Checkbox and add Radio primitive component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Implement Filter Parsers & Helpers in filters.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `common/components/filters.py`
|
||||||
|
- Modify: `tests/test_filter_helpers.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing unit tests for `_parse_bool_nullable` in `tests/test_filter_helpers.py`**
|
||||||
|
|
||||||
|
Add a new test class `ParseBoolNullableTest` to `tests/test_filter_helpers.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from common.components.filters import _parse_bool_nullable
|
||||||
|
|
||||||
|
class ParseBoolNullableTest(SimpleTestCase):
|
||||||
|
def test_missing_key(self):
|
||||||
|
self.assertIsNone(_parse_bool_nullable({}, "field"))
|
||||||
|
|
||||||
|
def test_null_value(self):
|
||||||
|
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
|
||||||
|
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
|
||||||
|
|
||||||
|
def test_boolean_values(self):
|
||||||
|
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
|
||||||
|
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
|
||||||
|
|
||||||
|
def test_string_values(self):
|
||||||
|
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field"))
|
||||||
|
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
||||||
|
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
||||||
|
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filter_helpers.py -k ParseBoolNullableTest`
|
||||||
|
Expected output: Failures/errors due to `_parse_bool_nullable` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `_parse_bool_nullable` and `_filter_boolean_radio` in `common/components/filters.py`**
|
||||||
|
|
||||||
|
1. Import `Checkbox` and `Radio` from `common.components.primitives` at the top of `common/components/filters.py`.
|
||||||
|
2. Define `_FILTER_RADIO_CLASS` and add `_parse_bool_nullable`.
|
||||||
|
3. Create `_filter_boolean_radio`.
|
||||||
|
4. Refactor `_filter_checkbox` to use `Checkbox` instead of raw `Label` and `Input`.
|
||||||
|
|
||||||
|
Code to implement:
|
||||||
|
```python
|
||||||
|
_FILTER_RADIO_CLASS = (
|
||||||
|
"rounded-full border-default-medium bg-neutral-secondary-medium "
|
||||||
|
"text-brand focus:ring-brand"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run unit tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filter_helpers.py`
|
||||||
|
Expected output: All helper tests passed (including `ParseBoolNullableTest`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add common/components/filters.py tests/test_filter_helpers.py
|
||||||
|
git commit -m "feat: implement _parse_bool_nullable and _filter_boolean_radio helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Replace Single Checkboxes with Radio Groups in Filter Bars
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `common/components/filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update GameFilterBar**
|
||||||
|
|
||||||
|
In `common/components/filters.py` inside `GameFilterBar`:
|
||||||
|
1. Parse using `_parse_bool_nullable` instead of `_parse_bool` for:
|
||||||
|
- `mastered_value`
|
||||||
|
- `purchase_refunded_value`
|
||||||
|
- `purchase_infinite_value`
|
||||||
|
- `session_emulated_value`
|
||||||
|
2. Update the fields list to replace `_filter_checkbox` with `_filter_boolean_radio`, changing the wrapper div to have `gap-6` for better horizontal radio button spacing.
|
||||||
|
|
||||||
|
Code snippet modification:
|
||||||
|
```python
|
||||||
|
# Parsing:
|
||||||
|
mastered_value = _parse_bool_nullable(existing, "mastered")
|
||||||
|
# ...
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Rendering (in fields):
|
||||||
|
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
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update SessionFilterBar**
|
||||||
|
|
||||||
|
In `common/components/filters.py` inside `SessionFilterBar`:
|
||||||
|
1. Parse using `_parse_bool_nullable` for:
|
||||||
|
- `emulated_value`
|
||||||
|
- `is_active_value`
|
||||||
|
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
|
||||||
|
|
||||||
|
Code snippet modification:
|
||||||
|
```python
|
||||||
|
# Parsing:
|
||||||
|
emulated_value = _parse_bool_nullable(existing, "emulated")
|
||||||
|
is_active_value = _parse_bool_nullable(existing, "is_active")
|
||||||
|
|
||||||
|
# Rendering (in fields):
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update PurchaseFilterBar**
|
||||||
|
|
||||||
|
In `common/components/filters.py` inside `PurchaseFilterBar`:
|
||||||
|
1. Parse using `_parse_bool_nullable` for:
|
||||||
|
- `is_refunded_value`
|
||||||
|
- `infinite_value`
|
||||||
|
- `needs_price_update_value`
|
||||||
|
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
|
||||||
|
|
||||||
|
Code snippet modification:
|
||||||
|
```python
|
||||||
|
# Parsing:
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Rendering (in fields):
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run component tests to verify output**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filter_bars.py`
|
||||||
|
Expected output: Since we only changed the internal input type from checkbox to radio but kept the `name="..."` attribute intact, the tests asserting name occurrences should still pass!
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add common/components/filters.py
|
||||||
|
git commit -m "feat: replace single boolean checkboxes with radio groups in all FilterBars"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Frontend Behavior and Serialization in JS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/static/js/filter_bar.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update Radio Serialization in `buildFilterJSON`**
|
||||||
|
|
||||||
|
In `games/static/js/filter_bar.js`, locate the `// 2. Boolean Fields (Checkboxes)` section.
|
||||||
|
Update the loop to check for `:checked` radio options:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 2. Boolean Fields (Radio Button Groups)
|
||||||
|
var booleanFields = [
|
||||||
|
{ name: "filter-mastered", key: "mastered" },
|
||||||
|
{ name: "filter-emulated", key: "emulated" },
|
||||||
|
{ name: "filter-active", key: "is_active" },
|
||||||
|
{ name: "filter-refunded", key: "is_refunded" },
|
||||||
|
{ name: "filter-infinite", key: "infinite" },
|
||||||
|
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||||
|
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||||
|
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||||
|
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||||
|
];
|
||||||
|
booleanFields.forEach(function (bf) {
|
||||||
|
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||||
|
if (el) {
|
||||||
|
var val = el.value === "true";
|
||||||
|
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add click-to-deselect functionality for radios**
|
||||||
|
|
||||||
|
In `games/static/js/filter_bar.js`, add `setupDeselectableRadios` and call it inside `DOMContentLoaded`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Enable deselect-on-click behavior for filter radio buttons.
|
||||||
|
*/
|
||||||
|
function setupDeselectableRadios() {
|
||||||
|
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||||
|
radio.addEventListener('click', function (e) {
|
||||||
|
if (this.wasChecked) {
|
||||||
|
this.checked = false;
|
||||||
|
this.wasChecked = false;
|
||||||
|
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
var name = this.getAttribute('name');
|
||||||
|
if (name) {
|
||||||
|
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||||
|
r.wasChecked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (radio.checked) {
|
||||||
|
radio.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Locate the `document.addEventListener("DOMContentLoaded", ...)` callback at the bottom of the file and update it:
|
||||||
|
```javascript
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
injectSearchInputs();
|
||||||
|
setupDeselectableRadios();
|
||||||
|
loadPresets();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run existing frontend / component tests to verify no syntax errors or simple breaks**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filter_bars.py`
|
||||||
|
Expected output: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add games/static/js/filter_bar.js
|
||||||
|
git commit -m "feat: add click-to-deselect behavior and update checked-radio serialization in JS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add Comprehensive Test Coverage & Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_filter_bars.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write explicit tests for boolean radio elements in filter bars**
|
||||||
|
|
||||||
|
Add a test case checking that the filter bars output `type="radio"` and contain `value="true"` and `value="false"` for boolean fields:
|
||||||
|
|
||||||
|
In `tests/test_filter_bars.py`, add the following test method:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_boolean_fields_render_as_radio_groups(self):
|
||||||
|
"""Boolean fields must render as radio groups with True/False choices."""
|
||||||
|
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
|
||||||
|
|
||||||
|
# 1. Games Filter Bar
|
||||||
|
games_html = str(FilterBar(filter_json=""))
|
||||||
|
self.assertIn('type="radio"', games_html)
|
||||||
|
self.assertIn('name="filter-mastered"', games_html)
|
||||||
|
self.assertIn('value="true"', games_html)
|
||||||
|
self.assertIn('value="false"', games_html)
|
||||||
|
|
||||||
|
# 2. Session Filter Bar
|
||||||
|
session_html = str(SessionFilterBar(filter_json=""))
|
||||||
|
self.assertIn('type="radio"', session_html)
|
||||||
|
self.assertIn('name="filter-emulated"', session_html)
|
||||||
|
self.assertIn('value="true"', session_html)
|
||||||
|
self.assertIn('value="false"', session_html)
|
||||||
|
|
||||||
|
# 3. Purchase Filter Bar
|
||||||
|
purchase_html = str(PurchaseFilterBar(filter_json=""))
|
||||||
|
self.assertIn('type="radio"', purchase_html)
|
||||||
|
self.assertIn('name="filter-refunded"', purchase_html)
|
||||||
|
self.assertIn('value="true"', purchase_html)
|
||||||
|
self.assertIn('value="false"', purchase_html)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run pytest to verify all tests (including new ones) pass**
|
||||||
|
|
||||||
|
Run: `pytest`
|
||||||
|
Expected output: `356 passed` (including the new test case).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit final tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add tests/test_filter_bars.py
|
||||||
|
git commit -m "test: add explicit radio group and True/False choice checks for boolean fields"
|
||||||
|
```
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Unify Form Checkboxes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Unify all Django form checkboxes across the codebase by routing them through our new Python `Checkbox` primitive.
|
||||||
|
**Architecture:**
|
||||||
|
1. Modify `Checkbox` and `Radio` primitives in `common/components/primitives.py` to support headless (label-less) rendering when `label` is `None`, so they can be injected into Django's native `form.as_div()` rendering without duplicating labels.
|
||||||
|
2. Create a `PrimitiveCheckboxWidget` in `games/forms.py` that extends `forms.CheckboxInput` but renders using our `Checkbox` Python component.
|
||||||
|
3. Create a `PrimitiveWidgetsMixin` in `games/forms.py` that automatically applies the `PrimitiveCheckboxWidget` to all `forms.BooleanField` instances in a form. Add this mixin to all ModelForms.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, Django Forms, HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Update Primitives for Headless Rendering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `common/components/primitives.py`
|
||||||
|
- Modify: `tests/test_components.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a failing test for headless rendering**
|
||||||
|
In `tests/test_components.py`, add a test to `ComponentPrimitivesTest`:
|
||||||
|
```python
|
||||||
|
def test_checkbox_headless(self):
|
||||||
|
html = Checkbox(name="test-headless", label=None, checked=True)
|
||||||
|
self.assertNotIn('<label', html)
|
||||||
|
self.assertIn('<input', html)
|
||||||
|
self.assertIn('type="checkbox"', html)
|
||||||
|
self.assertIn('name="test-headless"', html)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
Run: `pytest tests/test_components.py -k test_checkbox_headless`
|
||||||
|
Expected: Fail because `Checkbox` currently requires `label` as a `str` and always renders a `Label` wrapper.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `Checkbox` and `Radio` in `common/components/primitives.py`**
|
||||||
|
Update the function signatures to accept `label: str | None = None` and selectively return only the `Input` if `label` is missing.
|
||||||
|
```python
|
||||||
|
def Checkbox(
|
||||||
|
name: str,
|
||||||
|
label: str | None = None,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "1",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Checkbox component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
input_el = Input(type="checkbox", attributes=input_attrs)
|
||||||
|
if label is None:
|
||||||
|
return input_el
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||||
|
children=[input_el, label],
|
||||||
|
)
|
||||||
|
|
||||||
|
def Radio(
|
||||||
|
name: str,
|
||||||
|
label: str | None = None,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Radio component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
input_el = Input(type="radio", attributes=input_attrs)
|
||||||
|
if label is None:
|
||||||
|
return input_el
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||||
|
children=[input_el, label],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add common/components/primitives.py tests/test_components.py
|
||||||
|
git commit -m "refactor: allow Checkbox and Radio primitives to render headlessly without labels"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create Django Widget Adapter and Mixin
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/forms.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the Widget and Mixin implementations**
|
||||||
|
At the top of `games/forms.py`, import `Checkbox` and implement `PrimitiveCheckboxWidget` and `PrimitiveWidgetsMixin`.
|
||||||
|
```python
|
||||||
|
from common.components.primitives import Checkbox
|
||||||
|
|
||||||
|
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||||
|
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||||
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
|
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||||
|
checked = self.check_test(value)
|
||||||
|
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
|
||||||
|
|
||||||
|
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||||
|
return str(Checkbox(
|
||||||
|
name=name,
|
||||||
|
label=None,
|
||||||
|
checked=checked,
|
||||||
|
value=str(value) if value else "1",
|
||||||
|
attributes=attributes
|
||||||
|
))
|
||||||
|
|
||||||
|
class PrimitiveWidgetsMixin:
|
||||||
|
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field_name, field in self.fields.items():
|
||||||
|
if isinstance(field, forms.BooleanField):
|
||||||
|
field.widget = PrimitiveCheckboxWidget()
|
||||||
|
# Maintain the field's explicit required status (usually False for booleans)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the Mixin to all Forms**
|
||||||
|
In `games/forms.py`, update all the ModelForm classes to inherit from `PrimitiveWidgetsMixin` as the **first** base class (before `forms.ModelForm`).
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
|
# ...
|
||||||
|
|
||||||
|
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
|
# ...
|
||||||
|
|
||||||
|
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
|
# ...
|
||||||
|
|
||||||
|
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
|
# ...
|
||||||
|
|
||||||
|
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
|
# ...
|
||||||
|
|
||||||
|
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
|
# ...
|
||||||
|
|
||||||
|
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test Django Form Rendering**
|
||||||
|
Run the full test suite to ensure forms still validate properly and render without error.
|
||||||
|
Run: `pytest`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add games/forms.py
|
||||||
|
git commit -m "feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin"
|
||||||
|
```
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Design Spec: Boolean Filters Overhaul (Approach A with Reusable Primitives)
|
||||||
|
|
||||||
|
Expose a two-radio-button UI for all boolean filters to allow selecting "True" (Yes), "False" (No), or leaving the filter "Unset" (Not set).
|
||||||
|
|
||||||
|
## 1. Architectural Changes
|
||||||
|
|
||||||
|
### 1.1 Backend Primitives & Components
|
||||||
|
|
||||||
|
We will extract the `_filter_checkbox` rendering logic from `common/components/filters.py` and generalize it into a reusable, filter-agnostic `Checkbox` component in `common/components/primitives.py`. We will also add a corresponding `Radio` component.
|
||||||
|
|
||||||
|
#### In `common/components/primitives.py`:
|
||||||
|
```python
|
||||||
|
def Checkbox(
|
||||||
|
name: str,
|
||||||
|
label: str,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "1",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Checkbox component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||||
|
children=[
|
||||||
|
Input(type="checkbox", attributes=input_attrs),
|
||||||
|
label,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Radio(
|
||||||
|
name: str,
|
||||||
|
label: str,
|
||||||
|
checked: bool = False,
|
||||||
|
value: str = "",
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""A filter-agnostic Radio component."""
|
||||||
|
attributes = attributes or []
|
||||||
|
input_attrs = [
|
||||||
|
("name", name),
|
||||||
|
("value", value),
|
||||||
|
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||||
|
] + attributes
|
||||||
|
if checked:
|
||||||
|
input_attrs.append(("checked", "true"))
|
||||||
|
|
||||||
|
return Label(
|
||||||
|
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||||
|
children=[
|
||||||
|
Input(type="radio", attributes=input_attrs),
|
||||||
|
label,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### In `common/components/filters.py`:
|
||||||
|
We will import `Checkbox` and `Radio` from `common.components.primitives`. We will redefine `_filter_checkbox` as a thin adapter pointing to our new generalized `Checkbox` component (preserving any backward compatibility), and we will create a new helper `_filter_boolean_radio` using `Radio`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_FILTER_RADIO_CLASS = (
|
||||||
|
"rounded-full border-default-medium bg-neutral-secondary-medium "
|
||||||
|
"text-brand focus:ring-brand"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Parsing Filter JSON (Backend)
|
||||||
|
|
||||||
|
We will introduce a robust parsing function in `common/components/filters.py` to distinguish `True`, `False`, and `None` (unset):
|
||||||
|
|
||||||
|
```python
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 UI Overhauls in Filter Bars
|
||||||
|
|
||||||
|
We will update the following filter bars to use `_parse_bool_nullable` and `_filter_boolean_radio`:
|
||||||
|
1. **GameFilterBar:** `mastered`, `purchase_refunded`, `purchase_infinite`, `session_emulated`.
|
||||||
|
2. **SessionFilterBar:** `emulated`, `is_active`.
|
||||||
|
3. **PurchaseFilterBar:** `is_refunded`, `infinite`, `needs_price_update`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Frontend JS Changes (`games/static/js/filter_bar.js`)
|
||||||
|
|
||||||
|
### 2.1 Deselectable Radios Behavior
|
||||||
|
To support resetting filters back to "Unset" without resetting the whole form, we add click behavior that unchecks an already checked radio button when clicked.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function setupDeselectableRadios() {
|
||||||
|
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||||
|
radio.addEventListener('click', function (e) {
|
||||||
|
if (this.wasChecked) {
|
||||||
|
this.checked = false;
|
||||||
|
this.wasChecked = false;
|
||||||
|
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
var name = this.getAttribute('name');
|
||||||
|
if (name) {
|
||||||
|
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||||
|
r.wasChecked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (radio.checked) {
|
||||||
|
radio.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We will call `setupDeselectableRadios()` during `DOMContentLoaded`.
|
||||||
|
|
||||||
|
### 2.2 Serializing Radio States
|
||||||
|
Update `buildFilterJSON(form)` to collect checked radios from boolean field groups:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 2. Boolean Fields (Radio Button Groups)
|
||||||
|
var booleanFields = [
|
||||||
|
{ name: "filter-mastered", key: "mastered" },
|
||||||
|
{ name: "filter-emulated", key: "emulated" },
|
||||||
|
{ name: "filter-active", key: "is_active" },
|
||||||
|
{ name: "filter-refunded", key: "is_refunded" },
|
||||||
|
{ name: "filter-infinite", key: "infinite" },
|
||||||
|
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||||
|
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||||
|
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||||
|
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||||
|
];
|
||||||
|
booleanFields.forEach(function (bf) {
|
||||||
|
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||||
|
if (el) {
|
||||||
|
var val = el.value === "true";
|
||||||
|
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit Tests (`tests/test_filter_helpers.py`):**
|
||||||
|
- Add test coverage for `_parse_bool_nullable` covering `None`, `True`, `False`, strings, missing keys, etc.
|
||||||
|
2. **Component Tests (`tests/test_filter_bars.py`):**
|
||||||
|
- Update tests where the filters render checkbox elements to assert that radio groups are rendered instead (with "True" and "False" radio buttons).
|
||||||
|
3. **Integration and End-to-End Tests:**
|
||||||
|
- Execute the test suite using `pytest` to ensure that all 355 tests continue to pass and reflect the updated UI structure perfectly.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""End-to-end Playwright test for boolean radio filter serialization and deselect behavior.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
1. Selecting True/False serializes the boolean field as True/False.
|
||||||
|
2. Unsetting/unchecking a radio button by clicking on it again, which deselects it, omitting the field from JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from common.components import FilterBar
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_page(filter_json: str = "") -> str:
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Boolean filter E2E</title>
|
||||||
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def empty_bar_view(request):
|
||||||
|
return HttpResponse(_bar_page())
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("test-boolean-filter/", empty_bar_view),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_from_url(url: str) -> dict:
|
||||||
|
"""Extract and parse the ?filter=... query param from a URL."""
|
||||||
|
query = urllib.parse.urlparse(url).query
|
||||||
|
params = urllib.parse.parse_qs(query)
|
||||||
|
raw = params.get("filter", [""])[0]
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||||
|
def test_no_selection_omits_boolean_filters(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-boolean-filter/")
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert "mastered" not in parsed
|
||||||
|
assert "purchase_refunded" not in parsed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||||
|
def test_select_true_and_false_serializes_correctly(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-boolean-filter/")
|
||||||
|
|
||||||
|
# Select "True" for Mastered
|
||||||
|
# Under PurchaseFilterBar: "filter-mastered" is the mastered radio name.
|
||||||
|
# The true radio has value="true", false radio has value="false"
|
||||||
|
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
|
||||||
|
true_radio.click()
|
||||||
|
|
||||||
|
# Select "False" for Refunded (filter-purchase-refunded)
|
||||||
|
false_radio = page.locator('input[name="filter-purchase-refunded"][value="false"]')
|
||||||
|
false_radio.click()
|
||||||
|
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert parsed.get("mastered") == {"value": True, "modifier": "EQUALS"}
|
||||||
|
assert parsed.get("purchase_refunded") == {"value": False, "modifier": "EQUALS"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||||
|
def test_click_to_deselect_radio_works(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-boolean-filter/")
|
||||||
|
|
||||||
|
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
|
||||||
|
|
||||||
|
# First click checks it
|
||||||
|
true_radio.click()
|
||||||
|
assert true_radio.is_checked()
|
||||||
|
|
||||||
|
# Second click deselects it
|
||||||
|
true_radio.click()
|
||||||
|
assert not true_radio.is_checked()
|
||||||
|
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert "mastered" not in parsed
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""End-to-end Playwright test for the date-range filter widget's JS submit path.
|
||||||
|
|
||||||
|
Covers the one layer the Django-Client tests in ``test_rendered_pages.py``
|
||||||
|
cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
|
||||||
|
elements, building a ``DateCriterion`` JSON object, and navigating the
|
||||||
|
browser to ``?filter=<encoded>``.
|
||||||
|
|
||||||
|
Renders the bar at its own custom URL so the test doesn't need to auth
|
||||||
|
against the real app — the bar's JS doesn't care what route serves it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from common.components import PurchaseFilterBar
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_page(filter_json: str = "") -> str:
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Date filter E2E</title>
|
||||||
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def empty_bar_view(request):
|
||||||
|
return HttpResponse(_bar_page())
|
||||||
|
|
||||||
|
|
||||||
|
def prefilled_bar_view(request):
|
||||||
|
filter_json = json.dumps(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-03-15",
|
||||||
|
"value2": "2024-09-20",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return HttpResponse(_bar_page(filter_json))
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("test-date-filter/", empty_bar_view),
|
||||||
|
path("test-date-filter-prefilled/", prefilled_bar_view),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_from_url(url: str) -> dict:
|
||||||
|
"""Extract and parse the ?filter=... query param from a URL."""
|
||||||
|
query = urllib.parse.urlparse(url).query
|
||||||
|
params = urllib.parse.parse_qs(query)
|
||||||
|
raw = params.get("filter", [""])[0]
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||||
|
def test_both_dates_serializes_as_between(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-date-filter/")
|
||||||
|
page.locator('input[name="filter-date-purchased-min"]').fill("2024-01-01")
|
||||||
|
page.locator('input[name="filter-date-purchased-max"]').fill("2024-12-31")
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert parsed == {
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||||
|
def test_min_only_serializes_as_greater_than(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-date-filter/")
|
||||||
|
page.locator('input[name="filter-date-purchased-min"]').fill("2024-06-15")
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert parsed == {
|
||||||
|
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
|
||||||
|
}
|
||||||
|
# value2 must not be present when there's no upper bound.
|
||||||
|
assert "value2" not in parsed["date_purchased"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||||
|
def test_max_only_serializes_as_less_than(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-date-filter/")
|
||||||
|
page.locator('input[name="filter-date-refunded-max"]').fill("2025-06-30")
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert parsed == {
|
||||||
|
"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||||
|
def test_empty_inputs_omit_date_criterion(live_server, page):
|
||||||
|
"""No date typed → the filter JSON simply has no date_purchased /
|
||||||
|
date_refunded keys (vs. an empty-string crash)."""
|
||||||
|
page.goto(live_server.url + "/test-date-filter/")
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert "date_purchased" not in parsed
|
||||||
|
assert "date_refunded" not in parsed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||||
|
def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
|
||||||
|
"""A bar rendered with a BETWEEN filter_json pre-fills the inputs and
|
||||||
|
re-submits the same bounds unchanged."""
|
||||||
|
page.goto(live_server.url + "/test-date-filter-prefilled/")
|
||||||
|
assert (
|
||||||
|
page.locator('input[name="filter-date-purchased-min"]').input_value()
|
||||||
|
== "2024-03-15"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
page.locator('input[name="filter-date-purchased-max"]').input_value()
|
||||||
|
== "2024-09-20"
|
||||||
|
)
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert parsed["date_purchased"] == {
|
||||||
|
"value": "2024-03-15",
|
||||||
|
"value2": "2024-09-20",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from common.components import FilterBar
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_page(filter_json: str = "") -> str:
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Range Slider E2E</title>
|
||||||
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def empty_bar_view(request):
|
||||||
|
return HttpResponse(_bar_page())
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("test-range-slider/", empty_bar_view),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
# 1. Start with known state: Min is empty, Max is empty
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# 2. Type "20" into max input
|
||||||
|
max_input.fill("20")
|
||||||
|
|
||||||
|
# 3. Type "50" into min input (which is higher than 20)
|
||||||
|
min_input.fill("50")
|
||||||
|
|
||||||
|
# 4. Max input should have automatically synchronized/snapped to 50
|
||||||
|
assert max_input.input_value() == "50"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_crossover_max_less_than_min(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# 1. Type "50" into min input
|
||||||
|
min_input.fill("50")
|
||||||
|
|
||||||
|
# 2. Type "30" into max input (which is less than 50)
|
||||||
|
max_input.fill("30")
|
||||||
|
|
||||||
|
# 3. Min input should have automatically synchronized/snapped to 30
|
||||||
|
assert min_input.input_value() == "30"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# 1. Type value higher than dataMax (100 is max, type "150")
|
||||||
|
max_input.fill("150")
|
||||||
|
max_input.blur() # triggers "change" event
|
||||||
|
|
||||||
|
assert max_input.input_value() == "100"
|
||||||
|
|
||||||
|
# 2. Type value lower than dataMin (0 is min, type "-20")
|
||||||
|
min_input.fill("-20")
|
||||||
|
min_input.blur() # triggers "change" event
|
||||||
|
|
||||||
|
assert min_input.input_value() == "0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
# Locate handles
|
||||||
|
max_handle = page.locator('.range-handle-max[data-target="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||||
|
style = max_handle.get_attribute("style")
|
||||||
|
assert "left:100%" in style or "left: 100%" in style
|
||||||
|
|
||||||
|
# Set min to 50
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
min_input.fill("50")
|
||||||
|
|
||||||
|
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
|
||||||
|
style = max_handle.get_attribute("style")
|
||||||
|
assert "left:100%" in style or "left: 100%" in style
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""End-to-end Playwright test for String multi-mode filter serialization, null-state toggling, and prefill behaviors."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from common.components import PlatformFilterBar
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_page(filter_json: str = "") -> str:
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>String filter E2E</title>
|
||||||
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def empty_bar_view(request):
|
||||||
|
return HttpResponse(_bar_page())
|
||||||
|
|
||||||
|
|
||||||
|
def prefilled_bar_view(request):
|
||||||
|
filter_json = json.dumps(
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"value": "Switch",
|
||||||
|
"modifier": "INCLUDES",
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"modifier": "IS_NULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return HttpResponse(_bar_page(filter_json=filter_json))
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("test-string-filter-empty/", empty_bar_view),
|
||||||
|
path("test-string-filter-prefilled/", prefilled_bar_view),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_from_url(url: str) -> dict:
|
||||||
|
query = urllib.parse.urlparse(url).query
|
||||||
|
params = urllib.parse.parse_qs(query)
|
||||||
|
raw = params.get("filter", [""])[0]
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||||
|
def test_string_filter_defaults_and_toggles(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||||
|
|
||||||
|
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
|
||||||
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
|
assert name_input.is_enabled()
|
||||||
|
|
||||||
|
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
|
||||||
|
assert is_radio.is_checked()
|
||||||
|
|
||||||
|
# 2. Enter values, click "includes" (INCLUDES), and submit
|
||||||
|
name_input.fill("PlayStation")
|
||||||
|
includes_radio = page.locator('input[name="filter-name-modifier"][value="INCLUDES"]')
|
||||||
|
includes_radio.click()
|
||||||
|
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert parsed["name"] == {"value": "PlayStation", "modifier": "INCLUDES"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||||
|
def test_string_filter_null_states(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||||
|
|
||||||
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
|
name_input.fill("Xbox")
|
||||||
|
|
||||||
|
# Click "is null"
|
||||||
|
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||||
|
is_null_radio.click()
|
||||||
|
|
||||||
|
# Verification of interactive disabling
|
||||||
|
assert not name_input.is_enabled()
|
||||||
|
assert name_input.input_value() == ""
|
||||||
|
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('filter-bar-form')"
|
||||||
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
|
)
|
||||||
|
parsed = _filter_from_url(page.url)
|
||||||
|
assert parsed["name"] == {"modifier": "IS_NULL"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||||
|
def test_string_filter_prefilled_states(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-string-filter-prefilled/")
|
||||||
|
|
||||||
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
|
group_input = page.locator('input[name="filter-group"]')
|
||||||
|
|
||||||
|
# Verifies name matches "Switch" and "includes" is checked
|
||||||
|
assert name_input.input_value() == "Switch"
|
||||||
|
assert name_input.is_enabled()
|
||||||
|
assert page.locator('input[name="filter-name-modifier"][value="INCLUDES"]').is_checked()
|
||||||
|
|
||||||
|
# Verifies group is empty, disabled, and "is null" is checked
|
||||||
|
assert group_input.input_value() == ""
|
||||||
|
assert not group_input.is_enabled()
|
||||||
|
assert page.locator('input[name="filter-group-modifier"][value="IS_NULL"]').is_checked()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||||
|
def test_string_filter_deselect_re_enables(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||||
|
|
||||||
|
name_input = page.locator('input[name="filter-name"]')
|
||||||
|
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||||
|
|
||||||
|
# 1. Click "is null" -> disables input
|
||||||
|
is_null_radio.click()
|
||||||
|
assert not name_input.is_enabled()
|
||||||
|
|
||||||
|
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
|
||||||
|
is_null_radio.click()
|
||||||
|
assert name_input.is_enabled()
|
||||||
@@ -59,6 +59,12 @@ class GameOption(Schema): # mirrors SearchSelectOption
|
|||||||
data: dict
|
data: dict
|
||||||
|
|
||||||
|
|
||||||
|
class StringOption(Schema): # SearchSelectOption with a string value (e.g. group names)
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
data: dict
|
||||||
|
|
||||||
|
|
||||||
@game_router.get("/search", response=list[GameOption])
|
@game_router.get("/search", response=list[GameOption])
|
||||||
def search_games(request, q: str = "", limit: int = 10):
|
def search_games(request, q: str = "", limit: int = 10):
|
||||||
qs = Game.objects.select_related("platform").order_by("sort_name")
|
qs = Game.objects.select_related("platform").order_by("sort_name")
|
||||||
@@ -133,6 +139,15 @@ def search_platforms(request, q: str = "", limit: int = 10):
|
|||||||
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
|
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
@platform_router.get("/groups", response=list[StringOption])
|
||||||
|
def search_platform_groups(request, q: str = "", limit: int = 10):
|
||||||
|
qs = Platform.objects.exclude(group="")
|
||||||
|
if q:
|
||||||
|
qs = qs.filter(group__icontains=q)
|
||||||
|
groups = qs.values_list("group", flat=True).distinct().order_by("group")
|
||||||
|
return [{"value": group, "label": group, "data": {}} for group in groups[:limit]]
|
||||||
|
|
||||||
|
|
||||||
api.add_router("/playevent", playevent_router)
|
api.add_router("/playevent", playevent_router)
|
||||||
api.add_router("/games", game_router)
|
api.add_router("/games", game_router)
|
||||||
api.add_router("/devices", device_router)
|
api.add_router("/devices", device_router)
|
||||||
|
|||||||
+240
-44
@@ -18,6 +18,7 @@ from django.db.models import Q
|
|||||||
from common.criteria import (
|
from common.criteria import (
|
||||||
BoolCriterion,
|
BoolCriterion,
|
||||||
ChoiceCriterion,
|
ChoiceCriterion,
|
||||||
|
DateCriterion,
|
||||||
FloatCriterion,
|
FloatCriterion,
|
||||||
IntCriterion,
|
IntCriterion,
|
||||||
Modifier,
|
Modifier,
|
||||||
@@ -58,16 +59,36 @@ class GameFilter(OperatorFilter):
|
|||||||
original_year_released: IntCriterion | None = None
|
original_year_released: IntCriterion | None = None
|
||||||
wikidata: StringCriterion | None = None
|
wikidata: StringCriterion | None = None
|
||||||
platform: ChoiceCriterion | None = None # selectable filter widget
|
platform: ChoiceCriterion | None = None # selectable filter widget
|
||||||
|
platform_group: MultiCriterion | None = None # platform__group__in
|
||||||
status: ChoiceCriterion | None = None # selectable filter widget
|
status: ChoiceCriterion | None = None # selectable filter widget
|
||||||
mastered: BoolCriterion | None = None
|
mastered: BoolCriterion | None = None
|
||||||
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
|
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
|
||||||
created_at: StringCriterion | None = None # date string
|
created_at: StringCriterion | None = None # date string
|
||||||
updated_at: StringCriterion | None = None # date string
|
updated_at: StringCriterion | None = None # date string
|
||||||
|
|
||||||
has_purchases: BoolCriterion | None = None
|
|
||||||
has_playevents: BoolCriterion | None = None
|
|
||||||
session_count: IntCriterion | None = None
|
session_count: IntCriterion | None = None
|
||||||
session_average: IntCriterion | None = None # average in minutes
|
session_average: IntCriterion | None = None # average in minutes
|
||||||
|
purchase_count: IntCriterion | None = None # distinct purchases per game
|
||||||
|
playevent_count: IntCriterion | None = None # playevents per game
|
||||||
|
|
||||||
|
# Aggregate session durations (minutes), summed across the game's sessions
|
||||||
|
manual_playtime_minutes: IntCriterion | None = None
|
||||||
|
calculated_playtime_minutes: IntCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity: any session played on these devices / matching these flags
|
||||||
|
device: MultiCriterion | None = None # game has session on any of these devices
|
||||||
|
session_emulated: BoolCriterion | None = None # game has emulated session
|
||||||
|
|
||||||
|
# Cross-entity: matches against the game's purchases
|
||||||
|
purchase_refunded: BoolCriterion | None = None # game has refunded purchase
|
||||||
|
purchase_infinite: BoolCriterion | None = None # game has infinite purchase
|
||||||
|
purchase_price_total: FloatCriterion | None = None # sum of converted prices
|
||||||
|
purchase_price_any: FloatCriterion | None = None # any single purchase in range
|
||||||
|
purchase_type: ChoiceCriterion | None = None # game has purchase of type
|
||||||
|
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
|
||||||
|
|
||||||
|
# Cross-entity: substring match against the game's playevent notes
|
||||||
|
playevent_note: StringCriterion | None = None
|
||||||
|
|
||||||
# Free-text search (combines name + sort_name + platform name)
|
# Free-text search (combines name + sort_name + platform name)
|
||||||
search: StringCriterion | None = None
|
search: StringCriterion | None = None
|
||||||
@@ -105,34 +126,176 @@ class GameFilter(OperatorFilter):
|
|||||||
if self.updated_at is not None:
|
if self.updated_at is not None:
|
||||||
q &= self.updated_at.to_q("updated_at")
|
q &= self.updated_at.to_q("updated_at")
|
||||||
|
|
||||||
if self.has_purchases is not None:
|
if self.platform_group is not None:
|
||||||
from games.models import Purchase
|
q &= self.platform_group.to_q("platform__group")
|
||||||
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
|
|
||||||
if self.has_purchases.value:
|
|
||||||
q &= Q(id__in=purchased_ids)
|
|
||||||
else:
|
|
||||||
q &= ~Q(id__in=purchased_ids)
|
|
||||||
|
|
||||||
if self.has_playevents is not None:
|
|
||||||
from games.models import PlayEvent
|
|
||||||
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
|
|
||||||
if self.has_playevents.value:
|
|
||||||
q &= Q(id__in=played_ids)
|
|
||||||
else:
|
|
||||||
q &= ~Q(id__in=played_ids)
|
|
||||||
|
|
||||||
if self.session_count is not None:
|
if self.session_count is not None:
|
||||||
from games.models import Game
|
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
matching_ids = (
|
||||||
|
Game.objects.annotate(s_count=Count("sessions", distinct=True))
|
||||||
|
.filter(self.session_count.to_q("s_count"))
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.session_average is not None:
|
if self.session_average is not None:
|
||||||
from games.models import Game
|
|
||||||
from django.db.models import Avg
|
from django.db.models import Avg
|
||||||
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
matching_ids = (
|
||||||
|
Game.objects.annotate(s_avg=Avg("sessions__duration_total"))
|
||||||
|
.filter(self._playtime_to_q_for_field(self.session_average, "s_avg"))
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.purchase_count is not None:
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
matching_ids = (
|
||||||
|
Game.objects.annotate(p_count=Count("purchases", distinct=True))
|
||||||
|
.filter(self.purchase_count.to_q("p_count"))
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.playevent_count is not None:
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
matching_ids = (
|
||||||
|
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
|
||||||
|
.filter(self.playevent_count.to_q("pe_count"))
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.manual_playtime_minutes is not None:
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
matching_ids = (
|
||||||
|
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
||||||
|
.filter(
|
||||||
|
self._playtime_to_q_for_field(
|
||||||
|
self.manual_playtime_minutes, "s_manual"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.calculated_playtime_minutes is not None:
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
matching_ids = (
|
||||||
|
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
||||||
|
.filter(
|
||||||
|
self._playtime_to_q_for_field(
|
||||||
|
self.calculated_playtime_minutes, "s_calc"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.device is not None:
|
||||||
|
from games.models import Session
|
||||||
|
|
||||||
|
session_q = self.device.to_q("device_id")
|
||||||
|
matching_ids = Session.objects.filter(session_q).values_list(
|
||||||
|
"game_id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.session_emulated is not None:
|
||||||
|
from games.models import Session
|
||||||
|
|
||||||
|
emulated_ids = Session.objects.filter(
|
||||||
|
emulated=self.session_emulated.value
|
||||||
|
).values_list("game_id", flat=True)
|
||||||
|
if self.session_emulated.value:
|
||||||
|
q &= Q(id__in=emulated_ids)
|
||||||
|
else:
|
||||||
|
emulated_true_ids = Session.objects.filter(emulated=True).values_list(
|
||||||
|
"game_id", flat=True
|
||||||
|
)
|
||||||
|
q &= ~Q(id__in=emulated_true_ids)
|
||||||
|
|
||||||
|
if self.purchase_refunded is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
refunded_ids = Purchase.objects.filter(
|
||||||
|
date_refunded__isnull=False
|
||||||
|
).values_list("games__id", flat=True)
|
||||||
|
if self.purchase_refunded.value:
|
||||||
|
q &= Q(id__in=refunded_ids)
|
||||||
|
else:
|
||||||
|
q &= ~Q(id__in=refunded_ids)
|
||||||
|
|
||||||
|
if self.purchase_infinite is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
infinite_ids = Purchase.objects.filter(infinite=True).values_list(
|
||||||
|
"games__id", flat=True
|
||||||
|
)
|
||||||
|
if self.purchase_infinite.value:
|
||||||
|
q &= Q(id__in=infinite_ids)
|
||||||
|
else:
|
||||||
|
q &= ~Q(id__in=infinite_ids)
|
||||||
|
|
||||||
|
if self.purchase_price_total is not None:
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
matching_ids = (
|
||||||
|
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
|
||||||
|
.filter(self.purchase_price_total.to_q("p_total"))
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.purchase_price_any is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
price_q = self.purchase_price_any.to_q("converted_price")
|
||||||
|
matching_ids = Purchase.objects.filter(price_q).values_list(
|
||||||
|
"games__id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.purchase_type is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
type_q = self.purchase_type.to_q("type")
|
||||||
|
matching_ids = Purchase.objects.filter(type_q).values_list(
|
||||||
|
"games__id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.purchase_ownership_type is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
ownership_q = self.purchase_ownership_type.to_q("ownership_type")
|
||||||
|
matching_ids = Purchase.objects.filter(ownership_q).values_list(
|
||||||
|
"games__id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.playevent_note is not None:
|
||||||
|
q &= self._playevent_note_to_q(self.playevent_note)
|
||||||
|
|
||||||
# ── free-text search (OR across multiple fields) ──
|
# ── free-text search (OR across multiple fields) ──
|
||||||
if self.search is not None and self.search.value:
|
if self.search is not None and self.search.value:
|
||||||
search_q = (
|
search_q = (
|
||||||
@@ -147,26 +310,38 @@ class GameFilter(OperatorFilter):
|
|||||||
# Cross-entity filters
|
# Cross-entity filters
|
||||||
if self.session_filter is not None:
|
if self.session_filter is not None:
|
||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
session_q = self.session_filter.to_q()
|
session_q = self.session_filter.to_q()
|
||||||
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
|
matching_ids = Session.objects.filter(session_q).values_list(
|
||||||
|
"game_id", flat=True
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.purchase_filter is not None:
|
if self.purchase_filter is not None:
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
purchase_q = self.purchase_filter.to_q()
|
purchase_q = self.purchase_filter.to_q()
|
||||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
|
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||||
|
"games__id", flat=True
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.playevent_filter is not None:
|
if self.playevent_filter is not None:
|
||||||
from games.models import PlayEvent
|
from games.models import PlayEvent
|
||||||
|
|
||||||
playevent_q = self.playevent_filter.to_q()
|
playevent_q = self.playevent_filter.to_q()
|
||||||
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
|
matching_ids = PlayEvent.objects.filter(playevent_q).values_list(
|
||||||
|
"game_id", flat=True
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.platform_filter is not None:
|
if self.platform_filter is not None:
|
||||||
from games.models import Platform
|
from games.models import Platform
|
||||||
|
|
||||||
platform_q = self.platform_filter.to_q()
|
platform_q = self.platform_filter.to_q()
|
||||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||||
|
"id", flat=True
|
||||||
|
)
|
||||||
q &= Q(platform_id__in=matching_ids)
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
|
||||||
# ── AND / OR / NOT sub-filters ──
|
# ── AND / OR / NOT sub-filters ──
|
||||||
@@ -231,6 +406,15 @@ class GameFilter(OperatorFilter):
|
|||||||
return ~Q(**{f"{field}": timedelta(0)})
|
return ~Q(**{f"{field}": timedelta(0)})
|
||||||
return Q()
|
return Q()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _playevent_note_to_q(criterion: StringCriterion) -> Q:
|
||||||
|
"""Match games by substring / regex / null against their playevents' notes."""
|
||||||
|
from games.models import PlayEvent
|
||||||
|
|
||||||
|
event_q = criterion.to_q("note")
|
||||||
|
matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True)
|
||||||
|
return Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
|
||||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -268,6 +452,7 @@ class SessionFilter(OperatorFilter):
|
|||||||
|
|
||||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
q = Q()
|
q = Q()
|
||||||
td_val = timedelta(minutes=c.value)
|
td_val = timedelta(minutes=c.value)
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
@@ -323,7 +508,9 @@ class SessionFilter(OperatorFilter):
|
|||||||
if self.duration_manual_minutes is not None:
|
if self.duration_manual_minutes is not None:
|
||||||
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||||
if self.duration_calculated_minutes is not None:
|
if self.duration_calculated_minutes is not None:
|
||||||
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
|
q &= self._duration_to_q(
|
||||||
|
self.duration_calculated_minutes, "duration_calculated"
|
||||||
|
)
|
||||||
if self.is_active is not None:
|
if self.is_active is not None:
|
||||||
if self.is_active.value:
|
if self.is_active.value:
|
||||||
q &= Q(timestamp_end__isnull=True)
|
q &= Q(timestamp_end__isnull=True)
|
||||||
@@ -396,8 +583,8 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
name: StringCriterion | None = None
|
name: StringCriterion | None = None
|
||||||
platform: ChoiceCriterion | None = None # platform_id
|
platform: ChoiceCriterion | None = None # platform_id
|
||||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||||
date_purchased: StringCriterion | None = None # date string
|
date_purchased: DateCriterion | None = None
|
||||||
date_refunded: StringCriterion | None = None # date string
|
date_refunded: DateCriterion | None = None
|
||||||
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
||||||
price: FloatCriterion | None = None # on price field
|
price: FloatCriterion | None = None # on price field
|
||||||
converted_price: FloatCriterion | None = None
|
converted_price: FloatCriterion | None = None
|
||||||
@@ -483,7 +670,9 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
from games.models import Platform
|
from games.models import Platform
|
||||||
|
|
||||||
platform_q = self.platform_filter.to_q()
|
platform_q = self.platform_filter.to_q()
|
||||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||||
|
"id", flat=True
|
||||||
|
)
|
||||||
q &= Q(platform_id__in=matching_ids)
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
@@ -532,9 +721,9 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
subquery = subquery.filter(games=game_id)
|
subquery = subquery.filter(games=game_id)
|
||||||
|
|
||||||
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||||
extra_ids = Game.objects.exclude(
|
extra_ids = Game.objects.exclude(id__in=criterion.value).values_list(
|
||||||
id__in=criterion.value
|
"id", flat=True
|
||||||
).values_list("id", flat=True)
|
)
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
subquery = subquery.exclude(games__in=extra_ids)
|
subquery = subquery.exclude(games__in=extra_ids)
|
||||||
|
|
||||||
@@ -587,9 +776,8 @@ class DeviceFilter(OperatorFilter):
|
|||||||
|
|
||||||
# Free-text search
|
# Free-text search
|
||||||
if self.search is not None and self.search.value:
|
if self.search is not None and self.search.value:
|
||||||
search_q = (
|
search_q = Q(name__icontains=self.search.value) | Q(
|
||||||
Q(name__icontains=self.search.value)
|
type__icontains=self.search.value
|
||||||
| Q(type__icontains=self.search.value)
|
|
||||||
)
|
)
|
||||||
if self.search.modifier == Modifier.EXCLUDES:
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
search_q = ~search_q
|
search_q = ~search_q
|
||||||
@@ -598,8 +786,11 @@ class DeviceFilter(OperatorFilter):
|
|||||||
# Cross-entity filter: session_filter
|
# Cross-entity filter: session_filter
|
||||||
if self.session_filter is not None:
|
if self.session_filter is not None:
|
||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
session_q = self.session_filter.to_q()
|
session_q = self.session_filter.to_q()
|
||||||
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
|
matching_ids = Session.objects.filter(session_q).values_list(
|
||||||
|
"device_id", flat=True
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
@@ -651,9 +842,8 @@ class PlatformFilter(OperatorFilter):
|
|||||||
|
|
||||||
# Free-text search
|
# Free-text search
|
||||||
if self.search is not None and self.search.value:
|
if self.search is not None and self.search.value:
|
||||||
search_q = (
|
search_q = Q(name__icontains=self.search.value) | Q(
|
||||||
Q(name__icontains=self.search.value)
|
group__icontains=self.search.value
|
||||||
| Q(group__icontains=self.search.value)
|
|
||||||
)
|
)
|
||||||
if self.search.modifier == Modifier.EXCLUDES:
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
search_q = ~search_q
|
search_q = ~search_q
|
||||||
@@ -662,15 +852,21 @@ class PlatformFilter(OperatorFilter):
|
|||||||
# Cross-entity filter: game_filter
|
# Cross-entity filter: game_filter
|
||||||
if self.game_filter is not None:
|
if self.game_filter is not None:
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
game_q = self.game_filter.to_q()
|
game_q = self.game_filter.to_q()
|
||||||
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
|
matching_ids = Game.objects.filter(game_q).values_list(
|
||||||
|
"platform_id", flat=True
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
# Cross-entity filter: purchase_filter
|
# Cross-entity filter: purchase_filter
|
||||||
if self.purchase_filter is not None:
|
if self.purchase_filter is not None:
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
purchase_q = self.purchase_filter.to_q()
|
purchase_q = self.purchase_filter.to_q()
|
||||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
|
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||||
|
"platform_id", flat=True
|
||||||
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
@@ -727,9 +923,8 @@ class PlayEventFilter(OperatorFilter):
|
|||||||
|
|
||||||
# Free-text search
|
# Free-text search
|
||||||
if self.search is not None and self.search.value:
|
if self.search is not None and self.search.value:
|
||||||
search_q = (
|
search_q = Q(game__name__icontains=self.search.value) | Q(
|
||||||
Q(game__name__icontains=self.search.value)
|
note__icontains=self.search.value
|
||||||
| Q(note__icontains=self.search.value)
|
|
||||||
)
|
)
|
||||||
if self.search.modifier == Modifier.EXCLUDES:
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
search_q = ~search_q
|
search_q = ~search_q
|
||||||
@@ -738,6 +933,7 @@ class PlayEventFilter(OperatorFilter):
|
|||||||
# Cross-entity filter: game_filter
|
# Cross-entity filter: game_filter
|
||||||
if self.game_filter is not None:
|
if self.game_filter is not None:
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
game_q = self.game_filter.to_q()
|
game_q = self.game_filter.to_q()
|
||||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||||
q &= Q(game_id__in=matching_ids)
|
q &= Q(game_id__in=matching_ids)
|
||||||
|
|||||||
+35
-7
@@ -8,6 +8,7 @@ from common.components import (
|
|||||||
SearchSelectOption,
|
SearchSelectOption,
|
||||||
searchselect_selected,
|
searchselect_selected,
|
||||||
)
|
)
|
||||||
|
from common.components.primitives import Checkbox
|
||||||
from games.models import (
|
from games.models import (
|
||||||
Device,
|
Device,
|
||||||
Game,
|
Game,
|
||||||
@@ -25,6 +26,33 @@ custom_datetime_widget = forms.DateTimeInput(
|
|||||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||||
|
|
||||||
|
|
||||||
|
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||||
|
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||||
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
|
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||||
|
checked = self.check_test(value)
|
||||||
|
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
|
||||||
|
|
||||||
|
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||||
|
return str(Checkbox(
|
||||||
|
name=name,
|
||||||
|
label=None,
|
||||||
|
checked=checked,
|
||||||
|
value=str(value) if value else "1",
|
||||||
|
attributes=attributes
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class PrimitiveWidgetsMixin:
|
||||||
|
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field_name, field in self.fields.items():
|
||||||
|
if isinstance(field, forms.BooleanField):
|
||||||
|
field.widget = PrimitiveCheckboxWidget()
|
||||||
|
# Maintain the field's explicit required status (usually False for booleans)
|
||||||
|
|
||||||
|
|
||||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||||
def label_from_instance(self, obj) -> str:
|
def label_from_instance(self, obj) -> str:
|
||||||
return obj.search_label
|
return obj.search_label
|
||||||
@@ -128,7 +156,7 @@ class SearchSelectMultiple(SearchSelectWidget):
|
|||||||
return data.get(name)
|
return data.get(name)
|
||||||
|
|
||||||
|
|
||||||
class SessionForm(forms.ModelForm):
|
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
game = SingleGameChoiceField(
|
game = SingleGameChoiceField(
|
||||||
queryset=Game.objects.order_by("sort_name"),
|
queryset=Game.objects.order_by("sort_name"),
|
||||||
widget=SearchSelectWidget(
|
widget=SearchSelectWidget(
|
||||||
@@ -212,7 +240,7 @@ class RelatedPurchaseChoiceField(forms.ModelChoiceField):
|
|||||||
return name or obj.standardized_name
|
return name or obj.standardized_name
|
||||||
|
|
||||||
|
|
||||||
class PurchaseForm(forms.ModelForm):
|
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||||
@@ -305,7 +333,7 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
|||||||
return obj.sort_name
|
return obj.sort_name
|
||||||
|
|
||||||
|
|
||||||
class GameForm(forms.ModelForm):
|
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
platform = forms.ModelChoiceField(
|
platform = forms.ModelChoiceField(
|
||||||
queryset=Platform.objects.order_by("name"),
|
queryset=Platform.objects.order_by("name"),
|
||||||
required=False,
|
required=False,
|
||||||
@@ -329,7 +357,7 @@ class GameForm(forms.ModelForm):
|
|||||||
widgets = {"name": autofocus_input_widget}
|
widgets = {"name": autofocus_input_widget}
|
||||||
|
|
||||||
|
|
||||||
class PlatformForm(forms.ModelForm):
|
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = [
|
fields = [
|
||||||
@@ -340,14 +368,14 @@ class PlatformForm(forms.ModelForm):
|
|||||||
widgets = {"name": autofocus_input_widget}
|
widgets = {"name": autofocus_input_widget}
|
||||||
|
|
||||||
|
|
||||||
class DeviceForm(forms.ModelForm):
|
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = ["name", "type"]
|
fields = ["name", "type"]
|
||||||
widgets = {"name": autofocus_input_widget}
|
widgets = {"name": autofocus_input_widget}
|
||||||
|
|
||||||
|
|
||||||
class PlayEventForm(forms.ModelForm):
|
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
game = SingleGameChoiceField(
|
game = SingleGameChoiceField(
|
||||||
queryset=Game.objects.order_by("sort_name"),
|
queryset=Game.objects.order_by("sort_name"),
|
||||||
widget=SearchSelectWidget(
|
widget=SearchSelectWidget(
|
||||||
@@ -382,7 +410,7 @@ class PlayEventForm(forms.ModelForm):
|
|||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
class GameStatusChangeForm(forms.ModelForm):
|
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = GameStatusChange
|
model = GameStatusChange
|
||||||
fields = [
|
fields = [
|
||||||
|
|||||||
+43
-17
@@ -306,7 +306,6 @@
|
|||||||
--color-neutral-tertiary: var(--color-gray-100);
|
--color-neutral-tertiary: var(--color-gray-100);
|
||||||
--color-neutral-tertiary-medium: var(--color-gray-100);
|
--color-neutral-tertiary-medium: var(--color-gray-100);
|
||||||
--color-neutral-quaternary: var(--color-gray-200);
|
--color-neutral-quaternary: var(--color-gray-200);
|
||||||
--color-brand-soft: var(--color-blue-100);
|
|
||||||
--color-brand: var(--color-blue-700);
|
--color-brand: var(--color-blue-700);
|
||||||
--color-brand-medium: var(--color-blue-200);
|
--color-brand-medium: var(--color-blue-200);
|
||||||
--color-brand-strong: var(--color-blue-800);
|
--color-brand-strong: var(--color-blue-800);
|
||||||
@@ -467,6 +466,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.\@container {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
.pointer-events-auto {
|
.pointer-events-auto {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -1476,6 +1478,9 @@
|
|||||||
.h-8 {
|
.h-8 {
|
||||||
height: calc(var(--spacing) * 8);
|
height: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.h-9 {
|
||||||
|
height: calc(var(--spacing) * 9);
|
||||||
|
}
|
||||||
.h-10 {
|
.h-10 {
|
||||||
height: calc(var(--spacing) * 10);
|
height: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@@ -1740,6 +1745,9 @@
|
|||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.grid-cols-4 {
|
.grid-cols-4 {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1779,6 +1787,9 @@
|
|||||||
.gap-1 {
|
.gap-1 {
|
||||||
gap: calc(var(--spacing) * 1);
|
gap: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.gap-1\.5 {
|
||||||
|
gap: calc(var(--spacing) * 1.5);
|
||||||
|
}
|
||||||
.gap-2 {
|
.gap-2 {
|
||||||
gap: calc(var(--spacing) * 2);
|
gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -1791,6 +1802,9 @@
|
|||||||
.gap-5 {
|
.gap-5 {
|
||||||
gap: calc(var(--spacing) * 5);
|
gap: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
|
.gap-6 {
|
||||||
|
gap: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.space-y-6 {
|
.space-y-6 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -2700,6 +2714,9 @@
|
|||||||
.opacity-0 {
|
.opacity-0 {
|
||||||
opacity: 0%;
|
opacity: 0%;
|
||||||
}
|
}
|
||||||
|
.opacity-50 {
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
.opacity-100 {
|
.opacity-100 {
|
||||||
opacity: 100%;
|
opacity: 100%;
|
||||||
}
|
}
|
||||||
@@ -2753,6 +2770,11 @@
|
|||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
|
.transition-all {
|
||||||
|
transition-property: all;
|
||||||
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
|
}
|
||||||
.transition-opacity {
|
.transition-opacity {
|
||||||
transition-property: opacity;
|
transition-property: opacity;
|
||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
@@ -3337,6 +3359,11 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:grid-cols-4 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:rounded-t-lg {
|
.sm\:rounded-t-lg {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
border-top-left-radius: var(--radius-lg);
|
border-top-left-radius: var(--radius-lg);
|
||||||
@@ -3502,6 +3529,21 @@
|
|||||||
max-width: var(--breakpoint-2xl);
|
max-width: var(--breakpoint-2xl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.\@sm\:grid-cols-3 {
|
||||||
|
@container (width >= 24rem) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\@md\:grid-cols-4 {
|
||||||
|
@container (width >= 28rem) {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\@lg\:grid-cols-6 {
|
||||||
|
@container (width >= 32rem) {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.rtl\:rotate-180 {
|
.rtl\:rotate-180 {
|
||||||
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
@@ -4404,22 +4446,6 @@ form input:disabled, select:disabled, textarea:disabled {
|
|||||||
--tw-ring-color: var(--color-brand);
|
--tw-ring-color: var(--color-brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input[type="checkbox"] {
|
|
||||||
height: calc(var(--spacing) * 4);
|
|
||||||
width: calc(var(--spacing) * 4);
|
|
||||||
border-radius: var(--radius-xs);
|
|
||||||
border-style: var(--tw-border-style);
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: var(--color-default-medium);
|
|
||||||
background-color: var(--color-neutral-secondary-medium);
|
|
||||||
&:focus {
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
|
||||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
--tw-ring-color: var(--color-brand-soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: var(--radius-base);
|
border-radius: var(--radius-base);
|
||||||
|
|||||||
+183
-58
@@ -30,6 +30,24 @@
|
|||||||
return isNaN(val) ? "" : val;
|
return isNaN(val) ? "" : val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read a raw <input> value as string, or "" if not found. */
|
||||||
|
function stringValue(form, name) {
|
||||||
|
var el = form.querySelector('[name="' + name + '"]');
|
||||||
|
return el ? el.value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a range criterion ({value, value2?, modifier}) from a (min, max)
|
||||||
|
* pair, or null if both bounds are empty. Shared by the numeric-range and
|
||||||
|
* date-range serializers.
|
||||||
|
*/
|
||||||
|
function buildRangeCriterion(vMin, vMax) {
|
||||||
|
if (vMin !== "" && vMax !== "") return criterion(vMin, vMax, "BETWEEN");
|
||||||
|
if (vMin !== "") return criterion(vMin, null, "GREATER_THAN");
|
||||||
|
if (vMax !== "") return criterion(vMax, null, "LESS_THAN");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Read all checked checkboxes with a given name, returning an array of ints. */
|
/** Read all checked checkboxes with a given name, returning an array of ints. */
|
||||||
function checkedValues(form, name) {
|
function checkedValues(form, name) {
|
||||||
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
||||||
@@ -47,11 +65,6 @@
|
|||||||
*/
|
*/
|
||||||
function buildFilterJSON(form) {
|
function buildFilterJSON(form) {
|
||||||
var filter = {};
|
var filter = {};
|
||||||
var yearMin = numberValue(form, "filter-year-min");
|
|
||||||
var yearMax = numberValue(form, "filter-year-max");
|
|
||||||
var playMin = numberValue(form, "filter-playtime-min");
|
|
||||||
var playMax = numberValue(form, "filter-playtime-max");
|
|
||||||
var mastered = form.querySelector('[name="filter-mastered"]');
|
|
||||||
|
|
||||||
// ── Search field ──
|
// ── Search field ──
|
||||||
var searchInput = form.querySelector('[name="filter-search"]');
|
var searchInput = form.querySelector('[name="filter-search"]');
|
||||||
@@ -87,62 +100,100 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Session-specific fields ──
|
// 1. Text Fields
|
||||||
var pageIsSessions =
|
var textFields = [
|
||||||
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
|
{ name: "filter-price_currency", key: "price_currency" },
|
||||||
|
{ name: "filter-converted_currency", key: "converted_currency" },
|
||||||
|
{ name: "filter-name", key: "name" },
|
||||||
|
{ name: "filter-group", key: "group" },
|
||||||
|
{ name: "filter-playevent_note", key: "playevent_note" },
|
||||||
|
{ name: "filter-note", key: "note" }
|
||||||
|
];
|
||||||
|
textFields.forEach(function (tf) {
|
||||||
|
var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked');
|
||||||
|
var modifier = modifierEl ? modifierEl.value : "EQUALS";
|
||||||
|
|
||||||
// Emulated checkbox (sessions page)
|
var isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
|
||||||
var emulated = form.querySelector('[name="filter-emulated"]');
|
if (isPresence) {
|
||||||
if (emulated && emulated.checked) {
|
filter[tf.key] = { modifier: modifier };
|
||||||
filter.emulated = criterion(true, null, "EQUALS");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active checkbox (sessions page)
|
|
||||||
var active = form.querySelector('[name="filter-active"]');
|
|
||||||
if (active && active.checked) {
|
|
||||||
filter.is_active = criterion(true, null, "EQUALS");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (yearMin !== "" && yearMax !== "") {
|
|
||||||
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
|
|
||||||
} else if (yearMin !== "") {
|
|
||||||
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
|
|
||||||
} else if (yearMax !== "") {
|
|
||||||
filter.year_released = criterion(yearMax, null, "LESS_THAN");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playMin !== "" || playMax !== "") {
|
|
||||||
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
|
|
||||||
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
|
|
||||||
// Skip if both are 0 — means slider is at default (no real filter)
|
|
||||||
if (pMin === 0 && pMax === 0) {
|
|
||||||
// don't add filter
|
|
||||||
} else {
|
} else {
|
||||||
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
|
var el = form.querySelector('[name="' + tf.name + '"]');
|
||||||
if (playMin !== "" && playMax !== "") {
|
if (el && el.value.trim()) {
|
||||||
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
|
filter[tf.key] = { value: el.value.trim(), modifier: modifier };
|
||||||
} else if (playMin !== "") {
|
|
||||||
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
|
|
||||||
} else if (playMax !== "") {
|
|
||||||
filter[durKey] = criterion(pMax, null, "LESS_THAN");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Boolean Fields (Radio Button Groups)
|
||||||
|
var booleanFields = [
|
||||||
|
{ name: "filter-mastered", key: "mastered" },
|
||||||
|
{ name: "filter-emulated", key: "emulated" },
|
||||||
|
{ name: "filter-active", key: "is_active" },
|
||||||
|
{ name: "filter-refunded", key: "is_refunded" },
|
||||||
|
{ name: "filter-infinite", key: "infinite" },
|
||||||
|
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||||
|
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||||
|
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||||
|
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||||
|
];
|
||||||
|
booleanFields.forEach(function (bf) {
|
||||||
|
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||||
|
if (el) {
|
||||||
|
var val = el.value === "true";
|
||||||
|
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Range Fields
|
||||||
|
var rangeFields = [
|
||||||
|
{ prefix: "filter-year", key: "year_released" },
|
||||||
|
{ prefix: "filter-original-year", key: "original_year_released" },
|
||||||
|
{ prefix: "filter-session-count", key: "session_count" },
|
||||||
|
{ prefix: "filter-session-average", key: "session_average" },
|
||||||
|
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||||
|
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||||
|
{ prefix: "filter-duration-total-minutes", key: "duration_total_minutes" },
|
||||||
|
{ prefix: "filter-duration-manual-minutes", key: "duration_manual_minutes" },
|
||||||
|
{ prefix: "filter-duration-calculated-minutes", key: "duration_calculated_minutes" },
|
||||||
|
{ prefix: "filter-manual-playtime-minutes", key: "manual_playtime_minutes" },
|
||||||
|
{ prefix: "filter-calculated-playtime-minutes", key: "calculated_playtime_minutes" },
|
||||||
|
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||||
|
{ prefix: "filter-price", key: "price" },
|
||||||
|
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||||
|
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||||
|
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
||||||
|
{ prefix: "filter-playtime", key: "playtime_minutes", convert: function(v) { return Math.round(v * 60); }, ignoreZeroZero: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
rangeFields.forEach(function (rf) {
|
||||||
|
var vMin = numberValue(form, rf.prefix + "-min");
|
||||||
|
var vMax = numberValue(form, rf.prefix + "-max");
|
||||||
|
|
||||||
|
if (rf.convert) {
|
||||||
|
if (vMin !== "") vMin = rf.convert(vMin);
|
||||||
|
if (vMax !== "") vMax = rf.convert(vMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Purchase-specific: num_purchases ──
|
if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) {
|
||||||
var numGamesMin = numberValue(form, "filter-num-purchases-min");
|
return; // both 0 means slider at default
|
||||||
var numGamesMax = numberValue(form, "filter-num-purchases-max");
|
|
||||||
if (numGamesMin !== "" && numGamesMax !== "") {
|
|
||||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), parseInt(numGamesMax, 10), "BETWEEN");
|
|
||||||
} else if (numGamesMin !== "") {
|
|
||||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), null, "GREATER_THAN");
|
|
||||||
} else if (numGamesMax !== "") {
|
|
||||||
filter.num_purchases = criterion(parseInt(numGamesMax, 10), null, "LESS_THAN");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mastered && mastered.checked) {
|
var c = buildRangeCriterion(vMin, vMax);
|
||||||
filter.mastered = criterion(true, null, "EQUALS");
|
if (c !== null) filter[rf.key] = c;
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// 4. Date Range Fields — ISO date strings from <input type="date">; no
|
||||||
|
// numeric coercion. Same modifier derivation as numeric ranges.
|
||||||
|
var dateRangeFields = [
|
||||||
|
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
||||||
|
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
||||||
|
];
|
||||||
|
dateRangeFields.forEach(function (df) {
|
||||||
|
var vMin = stringValue(form, df.prefix + "-min");
|
||||||
|
var vMax = stringValue(form, df.prefix + "-max");
|
||||||
|
var c = buildRangeCriterion(vMin, vMax);
|
||||||
|
if (c !== null) filter[df.key] = c;
|
||||||
|
});
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
@@ -196,10 +247,19 @@
|
|||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
var mode = "games";
|
var mode = "games";
|
||||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
var path = window.location.pathname;
|
||||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
if (path.indexOf("session") !== -1) mode = "sessions";
|
||||||
|
else if (path.indexOf("purchase") !== -1) mode = "purchases";
|
||||||
|
else if (path.indexOf("device") !== -1) mode = "devices";
|
||||||
|
else if (path.indexOf("platform") !== -1) mode = "platforms";
|
||||||
|
else if (path.indexOf("playevent") !== -1) mode = "playevents";
|
||||||
|
|
||||||
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
|
var query = "";
|
||||||
|
if (url.indexOf("mode=") === -1) {
|
||||||
|
query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url + query, { credentials: "same-origin" })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
if (!r.ok) throw new Error("Failed to load presets");
|
if (!r.ok) throw new Error("Failed to load presets");
|
||||||
return r.text();
|
return r.text();
|
||||||
@@ -250,6 +310,27 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enable/disable the input text box depending on selected string modifier. */
|
||||||
|
window.toggleStringFilterInput = function (radio) {
|
||||||
|
var container = radio.closest(".flex-col");
|
||||||
|
if (!container) return;
|
||||||
|
var textInput = container.querySelector('input[type="text"]');
|
||||||
|
if (!textInput) return;
|
||||||
|
|
||||||
|
// Find the currently checked radio in the container
|
||||||
|
var checkedRadio = container.querySelector('input[type="radio"]:checked');
|
||||||
|
var val = checkedRadio ? checkedRadio.value : "";
|
||||||
|
|
||||||
|
if (val === "IS_NULL" || val === "NOT_NULL") {
|
||||||
|
textInput.disabled = true;
|
||||||
|
textInput.value = "";
|
||||||
|
textInput.classList.add("opacity-50", "cursor-not-allowed");
|
||||||
|
} else {
|
||||||
|
textInput.disabled = false;
|
||||||
|
textInput.classList.remove("opacity-50", "cursor-not-allowed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Show the preset name input field and the confirm button. */
|
/** Show the preset name input field and the confirm button. */
|
||||||
window.showPresetNameInput = function () {
|
window.showPresetNameInput = function () {
|
||||||
var input = document.getElementById("preset-name-input");
|
var input = document.getElementById("preset-name-input");
|
||||||
@@ -277,8 +358,12 @@
|
|||||||
var body = new URLSearchParams();
|
var body = new URLSearchParams();
|
||||||
body.append("name", name);
|
body.append("name", name);
|
||||||
var mode = "games";
|
var mode = "games";
|
||||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
var path = window.location.pathname;
|
||||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
if (path.indexOf("session") !== -1) mode = "sessions";
|
||||||
|
else if (path.indexOf("purchase") !== -1) mode = "purchases";
|
||||||
|
else if (path.indexOf("device") !== -1) mode = "devices";
|
||||||
|
else if (path.indexOf("platform") !== -1) mode = "platforms";
|
||||||
|
else if (path.indexOf("playevent") !== -1) mode = "playevents";
|
||||||
body.append("mode", mode);
|
body.append("mode", mode);
|
||||||
body.append("filter", JSON.stringify(filterObj));
|
body.append("filter", JSON.stringify(filterObj));
|
||||||
|
|
||||||
@@ -347,8 +432,48 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable deselect-on-click behavior for filter radio buttons.
|
||||||
|
*/
|
||||||
|
function setupDeselectableRadios() {
|
||||||
|
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||||
|
radio.addEventListener('click', function (e) {
|
||||||
|
if (this.wasChecked) {
|
||||||
|
this.checked = false;
|
||||||
|
this.wasChecked = false;
|
||||||
|
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
var name = this.getAttribute('name');
|
||||||
|
if (name) {
|
||||||
|
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||||
|
r.wasChecked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (radio.checked) {
|
||||||
|
radio.wasChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners for string modifier radio buttons.
|
||||||
|
*/
|
||||||
|
function setupStringFilters() {
|
||||||
|
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
|
||||||
|
radio.addEventListener('change', function () {
|
||||||
|
window.toggleStringFilterInput(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
injectSearchInputs();
|
injectSearchInputs();
|
||||||
|
setupDeselectableRadios();
|
||||||
|
setupStringFilters();
|
||||||
loadPresets();
|
loadPresets();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -46,8 +46,10 @@
|
|||||||
return Math.max(lo, Math.min(hi, value));
|
return Math.max(lo, Math.min(hi, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTargetValue(target) {
|
function getTargetValue(target, defaultVal) {
|
||||||
return parseInt(target ? target.value : 0, 10) || dataMin;
|
if (!target || target.value === "") return defaultVal;
|
||||||
|
var parsed = parseInt(target.value, 10);
|
||||||
|
return isNaN(parsed) ? defaultVal : parsed;
|
||||||
}
|
}
|
||||||
function setTargetValue(target, value) {
|
function setTargetValue(target, value) {
|
||||||
if (target) target.value = value;
|
if (target) target.value = value;
|
||||||
@@ -57,22 +59,30 @@
|
|||||||
|
|
||||||
function updateTrackFill() {
|
function updateTrackFill() {
|
||||||
if (!trackFill) return;
|
if (!trackFill) return;
|
||||||
var minValue = getTargetValue(minTarget);
|
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||||
var maxValue = getTargetValue(maxTarget);
|
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||||
if (mode === "point") {
|
if (mode === "point") {
|
||||||
trackFill.style.left = "0%";
|
trackFill.style.left = "0%";
|
||||||
trackFill.style.width = valueToPercent(maxValue) + "%";
|
trackFill.style.width = valueToPercent(maxVal) + "%";
|
||||||
} else {
|
} else {
|
||||||
var leftPct = valueToPercent(minValue);
|
var leftPct = valueToPercent(minVal);
|
||||||
var widthPct = valueToPercent(maxValue) - leftPct;
|
var rightPct = valueToPercent(maxVal);
|
||||||
|
if (leftPct > rightPct) {
|
||||||
|
var tmp = leftPct;
|
||||||
|
leftPct = rightPct;
|
||||||
|
rightPct = tmp;
|
||||||
|
}
|
||||||
|
var widthPct = rightPct - leftPct;
|
||||||
trackFill.style.left = leftPct + "%";
|
trackFill.style.left = leftPct + "%";
|
||||||
trackFill.style.width = widthPct + "%";
|
trackFill.style.width = widthPct + "%";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHandles() {
|
function updateHandles() {
|
||||||
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
|
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||||
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
|
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||||
|
minHandle.style.left = valueToPercent(minVal) + "%";
|
||||||
|
maxHandle.style.left = valueToPercent(maxVal) + "%";
|
||||||
updateTrackFill();
|
updateTrackFill();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +111,7 @@
|
|||||||
} else if (isMin) {
|
} else if (isMin) {
|
||||||
setTargetValue(
|
setTargetValue(
|
||||||
minTarget,
|
minTarget,
|
||||||
clamp(value, dataMin, getTargetValue(maxTarget))
|
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
|
||||||
);
|
);
|
||||||
if (minTarget)
|
if (minTarget)
|
||||||
minTarget.dispatchEvent(
|
minTarget.dispatchEvent(
|
||||||
@@ -110,7 +120,7 @@
|
|||||||
} else {
|
} else {
|
||||||
setTargetValue(
|
setTargetValue(
|
||||||
maxTarget,
|
maxTarget,
|
||||||
clamp(value, getTargetValue(minTarget), dataMax)
|
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
|
||||||
);
|
);
|
||||||
if (maxTarget)
|
if (maxTarget)
|
||||||
maxTarget.dispatchEvent(
|
maxTarget.dispatchEvent(
|
||||||
@@ -135,19 +145,49 @@
|
|||||||
|
|
||||||
// ── Sync from number inputs back to handles ──
|
// ── Sync from number inputs back to handles ──
|
||||||
|
|
||||||
function syncFromInputs() {
|
function syncFromInputs(e) {
|
||||||
if (mode === "point") {
|
if (mode === "point") {
|
||||||
var value =
|
var src = (e && e.target) || minTarget || maxTarget;
|
||||||
getTargetValue(minTarget) || getTargetValue(maxTarget);
|
var val = src ? src.value : "";
|
||||||
setTargetValue(minTarget, value);
|
setTargetValue(minTarget, val);
|
||||||
setTargetValue(maxTarget, value);
|
setTargetValue(maxTarget, val);
|
||||||
|
} else if (e && e.target) {
|
||||||
|
var minVal = getTargetValue(minTarget, dataMin);
|
||||||
|
var maxVal = getTargetValue(maxTarget, dataMax);
|
||||||
|
if (e.target === minTarget) {
|
||||||
|
if (minVal > maxVal) {
|
||||||
|
setTargetValue(maxTarget, minVal);
|
||||||
|
}
|
||||||
|
} else if (e.target === maxTarget) {
|
||||||
|
if (maxVal < minVal) {
|
||||||
|
setTargetValue(minTarget, maxVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateHandles();
|
updateHandles();
|
||||||
}
|
}
|
||||||
if (minTarget)
|
|
||||||
|
function enforceStrictBounds(e) {
|
||||||
|
if (e && e.target) {
|
||||||
|
var val = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
var clamped = clamp(val, dataMin, dataMax);
|
||||||
|
if (clamped !== val) {
|
||||||
|
setTargetValue(e.target, clamped);
|
||||||
|
e.target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minTarget) {
|
||||||
minTarget.addEventListener("input", syncFromInputs);
|
minTarget.addEventListener("input", syncFromInputs);
|
||||||
if (maxTarget)
|
minTarget.addEventListener("change", enforceStrictBounds);
|
||||||
|
}
|
||||||
|
if (maxTarget) {
|
||||||
maxTarget.addEventListener("input", syncFromInputs);
|
maxTarget.addEventListener("input", syncFromInputs);
|
||||||
|
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mode toggle ──
|
// ── Mode toggle ──
|
||||||
|
|
||||||
@@ -172,7 +212,7 @@
|
|||||||
var dashSpan = block && block.querySelector(".range-dash");
|
var dashSpan = block && block.querySelector(".range-dash");
|
||||||
if (newMode === "point") {
|
if (newMode === "point") {
|
||||||
minHandle.style.display = "none";
|
minHandle.style.display = "none";
|
||||||
setTargetValue(minTarget, getTargetValue(maxTarget));
|
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||||
if (minTarget) minTarget.classList.add("hidden");
|
if (minTarget) minTarget.classList.add("hidden");
|
||||||
if (dashSpan) dashSpan.classList.add("hidden");
|
if (dashSpan) dashSpan.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
const name = container.getAttribute("data-name");
|
const name = container.getAttribute("data-name");
|
||||||
const searchUrl = container.getAttribute("data-search-url");
|
const searchUrl = container.getAttribute("data-search-url");
|
||||||
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||||
|
const freeText = container.getAttribute("data-search-select-free-text") === "true";
|
||||||
const multi = container.getAttribute("data-multi") === "true";
|
const multi = container.getAttribute("data-multi") === "true";
|
||||||
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||||
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||||
@@ -251,6 +252,22 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// In free-text mode the typed text is the value itself: there is no
|
||||||
|
// backing list, so we rebuild a single ephemeral option row reflecting the
|
||||||
|
// current query so the +/− buttons (or Enter) can commit it as a pill.
|
||||||
|
const rebuildFreeTextRow = (query) => {
|
||||||
|
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
|
||||||
|
if (!query) {
|
||||||
|
setNoResults(false);
|
||||||
|
clearHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = buildRow({ value: query, label: query, data: {} });
|
||||||
|
options.insertBefore(row, noResults || null);
|
||||||
|
setNoResults(false);
|
||||||
|
highlightOption(row);
|
||||||
|
};
|
||||||
|
|
||||||
// Called on every keystroke. With a search_url, filter the loaded window
|
// Called on every keystroke. With a search_url, filter the loaded window
|
||||||
// instantly (zero latency) and debounce a server request for the rest;
|
// instantly (zero latency) and debounce a server request for the rest;
|
||||||
// no-results stays hidden until the response decides it, to avoid a flash
|
// no-results stays hidden until the response decides it, to avoid a flash
|
||||||
@@ -258,6 +275,11 @@
|
|||||||
// so the client-side filter is authoritative.
|
// so the client-side filter is authoritative.
|
||||||
const runSearch = () => {
|
const runSearch = () => {
|
||||||
const query = search.value.trim();
|
const query = search.value.trim();
|
||||||
|
if (freeText) {
|
||||||
|
rebuildFreeTextRow(query);
|
||||||
|
showPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (searchUrl) {
|
if (searchUrl) {
|
||||||
filterRows(query);
|
filterRows(query);
|
||||||
setNoResults(false);
|
setNoResults(false);
|
||||||
@@ -282,7 +304,9 @@
|
|||||||
search.value = "";
|
search.value = "";
|
||||||
container._searchSelectDirty = false;
|
container._searchSelectDirty = false;
|
||||||
}
|
}
|
||||||
if (searchUrl) {
|
if (freeText) {
|
||||||
|
rebuildFreeTextRow(search.value.trim());
|
||||||
|
} else if (searchUrl) {
|
||||||
if (prefetch && !hasPrefetched) {
|
if (prefetch && !hasPrefetched) {
|
||||||
// Seed the window immediately on first open (not debounced).
|
// Seed the window immediately on first open (not debounced).
|
||||||
hasPrefetched = true;
|
hasPrefetched = true;
|
||||||
|
|||||||
+4
-5
@@ -6,6 +6,7 @@ from django.http import HttpResponse
|
|||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import Component, CsrfInput, Div, Input
|
from common.components import Component, CsrfInput, Div, Input
|
||||||
|
from common.components.primitives import Td, Tr
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
|
|
||||||
|
|
||||||
@@ -15,12 +16,10 @@ def _login_content(form, request) -> SafeText:
|
|||||||
children=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
mark_safe(str(form.as_table())),
|
mark_safe(str(form.as_table())),
|
||||||
Component(
|
Tr(
|
||||||
tag_name="tr",
|
|
||||||
children=[
|
children=[
|
||||||
Component(tag_name="td"),
|
Td(),
|
||||||
Component(
|
Td(
|
||||||
tag_name="td",
|
|
||||||
children=[
|
children=[
|
||||||
Input(type="submit", attributes=[("value", "Login")])
|
Input(type="submit", attributes=[("value", "Login")])
|
||||||
],
|
],
|
||||||
|
|||||||
+30
-53
@@ -2,15 +2,16 @@ from typing import Any
|
|||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.middleware.csrf import get_token
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.middleware.csrf import get_token
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.defaultfilters import date as date_filter
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
H1,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,9 +22,7 @@ from common.components import (
|
|||||||
FilterBar,
|
FilterBar,
|
||||||
GameStatus,
|
GameStatus,
|
||||||
GameStatusSelector,
|
GameStatusSelector,
|
||||||
H1,
|
|
||||||
Icon,
|
Icon,
|
||||||
SearchField,
|
|
||||||
LinkedPurchase,
|
LinkedPurchase,
|
||||||
Modal,
|
Modal,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
@@ -31,9 +30,12 @@ from common.components import (
|
|||||||
Popover,
|
Popover,
|
||||||
PopoverTruncated,
|
PopoverTruncated,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
|
SearchField,
|
||||||
SimpleTable,
|
SimpleTable,
|
||||||
|
Ul,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
|
from common.components.primitives import Li, P, Span, Strong
|
||||||
from common.icons import get_icon
|
from common.icons import get_icon
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
@@ -193,19 +195,13 @@ def _delete_game_confirmation_modal(
|
|||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
data_items = []
|
data_items = []
|
||||||
if session_count:
|
if session_count:
|
||||||
data_items.append(
|
data_items.append(Li(children=[f"{session_count} session(s)"]))
|
||||||
Component(tag_name="li", children=[f"{session_count} session(s)"])
|
|
||||||
)
|
|
||||||
if purchase_count:
|
if purchase_count:
|
||||||
data_items.append(
|
data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
|
||||||
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
|
|
||||||
)
|
|
||||||
if playevent_count:
|
if playevent_count:
|
||||||
data_items.append(
|
data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
|
||||||
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
|
|
||||||
)
|
|
||||||
if not (session_count or purchase_count or playevent_count):
|
if not (session_count or purchase_count or playevent_count):
|
||||||
data_items.append(Component(tag_name="li", children=["No associated data"]))
|
data_items.append(Li(children=["No associated data"]))
|
||||||
|
|
||||||
form = Component(
|
form = Component(
|
||||||
tag_name="form",
|
tag_name="form",
|
||||||
@@ -218,8 +214,7 @@ def _delete_game_confirmation_modal(
|
|||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
Component(
|
P(
|
||||||
tag_name="p",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -231,8 +226,7 @@ def _delete_game_confirmation_modal(
|
|||||||
"This will permanently delete this game and all associated data:"
|
"This will permanently delete this game and all associated data:"
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Component(
|
Ul(
|
||||||
tag_name="ul",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -242,8 +236,7 @@ def _delete_game_confirmation_modal(
|
|||||||
],
|
],
|
||||||
children=data_items,
|
children=data_items,
|
||||||
),
|
),
|
||||||
Component(
|
P(
|
||||||
tag_name="p",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -279,8 +272,7 @@ def _delete_game_confirmation_modal(
|
|||||||
return Modal(
|
return Modal(
|
||||||
"delete-game-confirmation-modal",
|
"delete-game-confirmation-modal",
|
||||||
children=[
|
children=[
|
||||||
Component(
|
P(
|
||||||
tag_name="h1",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -289,12 +281,11 @@ def _delete_game_confirmation_modal(
|
|||||||
],
|
],
|
||||||
children=["Delete Game"],
|
children=["Delete Game"],
|
||||||
),
|
),
|
||||||
Component(
|
P(
|
||||||
tag_name="p",
|
|
||||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||||
children=[
|
children=[
|
||||||
"Are you sure you want to delete ",
|
"Are you sure you want to delete ",
|
||||||
Component(tag_name="strong", children=[game.name]),
|
Strong(children=[game.name]),
|
||||||
"?",
|
"?",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -427,9 +418,7 @@ def _meta_row(
|
|||||||
label: str, value: SafeText | str, extra: SafeText | str = ""
|
label: str, value: SafeText | str, extra: SafeText | str = ""
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
children: list[SafeText | str] = [
|
children: list[SafeText | str] = [
|
||||||
Component(
|
Span(attributes=[("class", "uppercase")], children=[label]),
|
||||||
tag_name="span", attributes=[("class", "uppercase")], children=[label]
|
|
||||||
),
|
|
||||||
value,
|
value,
|
||||||
]
|
]
|
||||||
if extra:
|
if extra:
|
||||||
@@ -452,9 +441,8 @@ def _game_action_buttons(game: Game) -> SafeText:
|
|||||||
"dark:text-white dark:hover:text-white dark:hover:bg-red-700 "
|
"dark:text-white dark:hover:text-white dark:hover:bg-red-700 "
|
||||||
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
|
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
|
||||||
)
|
)
|
||||||
edit_link = Component(
|
edit_link = A(
|
||||||
tag_name="a",
|
href=reverse("games:edit_game", args=[game.id]),
|
||||||
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
|
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Component(
|
||||||
tag_name="button",
|
tag_name="button",
|
||||||
@@ -463,10 +451,9 @@ def _game_action_buttons(game: Game) -> SafeText:
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
delete_link = Component(
|
delete_link = A(
|
||||||
tag_name="a",
|
href="#",
|
||||||
attributes=[
|
attributes=[
|
||||||
("href", "#"),
|
|
||||||
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
|
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
|
||||||
("hx-target", "#global-modal-container"),
|
("hx-target", "#global-modal-container"),
|
||||||
],
|
],
|
||||||
@@ -499,21 +486,16 @@ def _game_history(statuschanges) -> SafeText:
|
|||||||
status=change.new_status,
|
status=change.new_status,
|
||||||
children=[change.get_new_status_display()],
|
children=[change.get_new_status_display()],
|
||||||
)
|
)
|
||||||
edit = Component(
|
edit = A(
|
||||||
tag_name="a",
|
href=reverse("games:edit_statuschange", args=[change.id]),
|
||||||
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
|
|
||||||
children=["Edit"],
|
children=["Edit"],
|
||||||
)
|
)
|
||||||
delete = Component(
|
delete = A(
|
||||||
tag_name="a",
|
href=reverse("games:delete_statuschange", args=[change.id]),
|
||||||
attributes=[
|
|
||||||
("href", reverse("games:delete_statuschange", args=[change.id]))
|
|
||||||
],
|
|
||||||
children=["Delete"],
|
children=["Delete"],
|
||||||
)
|
)
|
||||||
items.append(
|
items.append(
|
||||||
Component(
|
Li(
|
||||||
tag_name="li",
|
|
||||||
attributes=[("class", "text-slate-500")],
|
attributes=[("class", "text-slate-500")],
|
||||||
children=[
|
children=[
|
||||||
f"{prefix} status from ",
|
f"{prefix} status from ",
|
||||||
@@ -528,8 +510,7 @@ def _game_history(statuschanges) -> SafeText:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return Component(
|
return Ul(
|
||||||
tag_name="ul",
|
|
||||||
attributes=[("class", "list-disc list-inside")],
|
attributes=[("class", "list-disc list-inside")],
|
||||||
children=items,
|
children=items,
|
||||||
)
|
)
|
||||||
@@ -576,12 +557,10 @@ def _game_overview_metrics(game: Game) -> dict[str, Any]:
|
|||||||
|
|
||||||
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
|
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
|
||||||
grey_value_class = "text-black dark:text-slate-300"
|
grey_value_class = "text-black dark:text-slate-300"
|
||||||
title_span = Component(
|
title_span = Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "text-balance max-w-120 text-4xl")],
|
attributes=[("class", "text-balance max-w-120 text-4xl")],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "font-bold font-serif")],
|
attributes=[("class", "font-bold font-serif")],
|
||||||
children=[game.name],
|
children=[game.name],
|
||||||
),
|
),
|
||||||
@@ -634,8 +613,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
|
|||||||
[
|
[
|
||||||
_meta_row(
|
_meta_row(
|
||||||
"Original year",
|
"Original year",
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", grey_value_class)],
|
attributes=[("class", grey_value_class)],
|
||||||
children=[str(game.original_year_released)],
|
children=[str(game.original_year_released)],
|
||||||
),
|
),
|
||||||
@@ -648,8 +626,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
|
|||||||
_played_row(game, request),
|
_played_row(game, request),
|
||||||
_meta_row(
|
_meta_row(
|
||||||
"Platform",
|
"Platform",
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", grey_value_class)],
|
attributes=[("class", grey_value_class)],
|
||||||
children=[str(game.platform)],
|
children=[str(game.platform)],
|
||||||
),
|
),
|
||||||
|
|||||||
+17
-21
@@ -6,13 +6,12 @@ from django.http import (
|
|||||||
HttpResponseRedirect,
|
HttpResponseRedirect,
|
||||||
)
|
)
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.views.decorators.http import require_POST
|
|
||||||
|
|
||||||
from django.template.defaultfilters import date as date_filter
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
@@ -32,6 +31,7 @@ from common.components import (
|
|||||||
TableRow,
|
TableRow,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
|
from common.components.primitives import Li, P, Td, Tr, Ul
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat
|
from common.time import dateformat
|
||||||
from common.utils import paginate
|
from common.utils import paginate
|
||||||
@@ -129,7 +129,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
from common.components import PurchaseFilterBar, ModuleScript
|
from common.components import ModuleScript, PurchaseFilterBar
|
||||||
|
|
||||||
filter_bar = PurchaseFilterBar(
|
filter_bar = PurchaseFilterBar(
|
||||||
filter_json=filter_json,
|
filter_json=filter_json,
|
||||||
@@ -149,12 +149,10 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
def _purchase_additional_row() -> SafeText:
|
def _purchase_additional_row() -> SafeText:
|
||||||
"""The 'Submit & Create Session' row shown below the main Submit button."""
|
"""The 'Submit & Create Session' row shown below the main Submit button."""
|
||||||
return Component(
|
return Tr(
|
||||||
tag_name="tr",
|
|
||||||
children=[
|
children=[
|
||||||
Component(tag_name="td"),
|
Td(),
|
||||||
Component(
|
Td(
|
||||||
tag_name="td",
|
|
||||||
children=[
|
children=[
|
||||||
Button(
|
Button(
|
||||||
[],
|
[],
|
||||||
@@ -262,8 +260,7 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
|||||||
Div(
|
Div(
|
||||||
[("class", row_class)],
|
[("class", row_class)],
|
||||||
[
|
[
|
||||||
Component(
|
P(
|
||||||
tag_name="p",
|
|
||||||
children=[
|
children=[
|
||||||
"Price per game: ",
|
"Price per game: ",
|
||||||
PriceConverted([floatformat(purchase.price_per_game, 0)]),
|
PriceConverted([floatformat(purchase.price_per_game, 0)]),
|
||||||
@@ -273,10 +270,9 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
Div([("class", row_class)], ["Games included in this purchase:"]),
|
Div([("class", row_class)], ["Games included in this purchase:"]),
|
||||||
Component(
|
Ul(
|
||||||
tag_name="ul",
|
|
||||||
children=[
|
children=[
|
||||||
Component(tag_name="li", children=[GameLink(game.id, game.name)])
|
Li(children=[GameLink(game.id, game.name)])
|
||||||
for game in purchase.games.all()
|
for game in purchase.games.all()
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -317,8 +313,7 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
|||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
Component(
|
P(
|
||||||
tag_name="p",
|
|
||||||
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
|
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
|
||||||
children=["Games will be marked as abandoned."],
|
children=["Games will be marked as abandoned."],
|
||||||
),
|
),
|
||||||
@@ -356,8 +351,7 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
|||||||
],
|
],
|
||||||
children=["Confirm Refund"],
|
children=["Confirm Refund"],
|
||||||
),
|
),
|
||||||
Component(
|
P(
|
||||||
tag_name="p",
|
|
||||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||||
children=["Are you sure you want to mark this purchase as refunded?"],
|
children=["Are you sure you want to mark this purchase as refunded?"],
|
||||||
),
|
),
|
||||||
@@ -408,8 +402,10 @@ def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
|||||||
from games.forms import related_purchase_queryset
|
from games.forms import related_purchase_queryset
|
||||||
|
|
||||||
form = PurchaseForm()
|
form = PurchaseForm()
|
||||||
qs = related_purchase_queryset().filter(games__in=games).order_by(
|
qs = (
|
||||||
"games__sort_name"
|
related_purchase_queryset()
|
||||||
|
.filter(games__in=games)
|
||||||
|
.order_by("games__sort_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
form.fields["related_purchase"].queryset = qs
|
form.fields["related_purchase"].queryset = qs
|
||||||
|
|||||||
+13
-22
@@ -15,7 +15,6 @@ from common.components import (
|
|||||||
AddForm,
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Component,
|
|
||||||
Div,
|
Div,
|
||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
@@ -25,6 +24,7 @@ from common.components import (
|
|||||||
SessionDeviceSelector,
|
SessionDeviceSelector,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
|
from common.components.primitives import Span, Td, Tr
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
@@ -208,8 +208,7 @@ def _session_fields(form) -> SafeText:
|
|||||||
this_side = "start" if field.name == "timestamp_start" else "end"
|
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||||
other_side = "end" if field.name == "timestamp_start" else "start"
|
other_side = "end" if field.name == "timestamp_start" else "start"
|
||||||
children.append(
|
children.append(
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -292,8 +291,8 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
def _session_row_fragment(session: Session) -> SafeText:
|
def _session_row_fragment(session: Session) -> SafeText:
|
||||||
"""A single session <tr> (the old list_sessions.html#session-row partial),
|
"""A single session <tr> (the old list_sessions.html#session-row partial),
|
||||||
returned by the inline end/clone-session HTMX endpoints."""
|
returned by the inline end/clone-session HTMX endpoints."""
|
||||||
name_link = Component(
|
name_link = A(
|
||||||
tag_name="a",
|
href=reverse("games:view_game", args=[session.game.id]),
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -305,12 +304,10 @@ def _session_row_fragment(session: Session) -> SafeText:
|
|||||||
"group-hover:outline-purple-400 group-hover:outline-4 "
|
"group-hover:outline-purple-400 group-hover:outline-4 "
|
||||||
"group-hover:decoration-purple-900 group-hover:text-purple-100",
|
"group-hover:decoration-purple-900 group-hover:text-purple-100",
|
||||||
),
|
),
|
||||||
("href", reverse("games:view_game", args=[session.game.id])),
|
|
||||||
],
|
],
|
||||||
children=[session.game.name],
|
children=[session.game.name],
|
||||||
)
|
)
|
||||||
name_td = Component(
|
name_td = Td(
|
||||||
tag_name="td",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -319,15 +316,13 @@ def _session_row_fragment(session: Session) -> SafeText:
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "inline-block relative")],
|
attributes=[("class", "inline-block relative")],
|
||||||
children=[name_link],
|
children=[name_link],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
start_td = Component(
|
start_td = Td(
|
||||||
tag_name="td",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
|
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
|
||||||
],
|
],
|
||||||
@@ -336,10 +331,9 @@ def _session_row_fragment(session: Session) -> SafeText:
|
|||||||
|
|
||||||
if not session.timestamp_end:
|
if not session.timestamp_end:
|
||||||
end_url = reverse("games:list_sessions_end_session", args=[session.id])
|
end_url = reverse("games:list_sessions_end_session", args=[session.id])
|
||||||
end_inner: SafeText | str = Component(
|
end_inner: SafeText | str = A(
|
||||||
tag_name="a",
|
href=end_url,
|
||||||
attributes=[
|
attributes=[
|
||||||
("href", end_url),
|
|
||||||
("hx-get", end_url),
|
("hx-get", end_url),
|
||||||
("hx-target", "closest tr"),
|
("hx-target", "closest tr"),
|
||||||
("hx-swap", "outerHTML"),
|
("hx-swap", "outerHTML"),
|
||||||
@@ -351,8 +345,7 @@ def _session_row_fragment(session: Session) -> SafeText:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "text-yellow-300")],
|
attributes=[("class", "text-yellow-300")],
|
||||||
children=["Finish now?"],
|
children=["Finish now?"],
|
||||||
)
|
)
|
||||||
@@ -362,19 +355,17 @@ def _session_row_fragment(session: Session) -> SafeText:
|
|||||||
end_inner = "--"
|
end_inner = "--"
|
||||||
else:
|
else:
|
||||||
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
|
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
|
||||||
end_td = Component(
|
end_td = Td(
|
||||||
tag_name="td",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
|
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
|
||||||
],
|
],
|
||||||
children=[end_inner],
|
children=[end_inner],
|
||||||
)
|
)
|
||||||
duration_td = Component(
|
duration_td = Td(
|
||||||
tag_name="td",
|
|
||||||
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
|
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
|
||||||
children=[session.duration_formatted()],
|
children=[session.duration_formatted()],
|
||||||
)
|
)
|
||||||
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
|
return Tr(children=[name_td, start_td, end_td, duration_td])
|
||||||
|
|
||||||
|
|
||||||
def clone_session_by_id(session_id: int) -> Session:
|
def clone_session_by_id(session_id: int) -> Session:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from common.components import (
|
|||||||
Div,
|
Div,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
|
from common.components.primitives import P
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
from common.utils import paginate
|
from common.utils import paginate
|
||||||
@@ -75,8 +76,7 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
|||||||
inner = Div(
|
inner = Div(
|
||||||
[],
|
[],
|
||||||
[
|
[
|
||||||
Component(
|
P(
|
||||||
tag_name="p",
|
|
||||||
children=["Are you sure you want to delete this status change?"],
|
children=["Are you sure you want to delete this status change?"],
|
||||||
),
|
),
|
||||||
Button(
|
Button(
|
||||||
|
|||||||
@@ -821,5 +821,60 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
|||||||
self.assertIn("2025-01-01", tbody)
|
self.assertIn("2025-01-01", tbody)
|
||||||
|
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from common.components.primitives import Checkbox, Radio
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentPrimitivesTest(SimpleTestCase):
|
||||||
|
def test_checkbox_primitive(self):
|
||||||
|
html = Checkbox(
|
||||||
|
name="test-check", label="Accept Terms", checked=True, value="yes"
|
||||||
|
)
|
||||||
|
self.assertIn('type="checkbox"', html)
|
||||||
|
self.assertIn('name="test-check"', html)
|
||||||
|
self.assertIn('value="yes"', html)
|
||||||
|
self.assertIn('checked="true"', html)
|
||||||
|
self.assertIn("Accept Terms", html)
|
||||||
|
|
||||||
|
def test_checkbox_headless(self):
|
||||||
|
html = Checkbox(name="test-headless", label=None, checked=True)
|
||||||
|
self.assertNotIn("<label", html)
|
||||||
|
self.assertIn("<input", html)
|
||||||
|
self.assertIn('type="checkbox"', html)
|
||||||
|
self.assertIn('name="test-headless"', html)
|
||||||
|
|
||||||
|
def test_radio_primitive(self):
|
||||||
|
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
|
||||||
|
self.assertIn('type="radio"', html)
|
||||||
|
self.assertIn('name="test-radio"', html)
|
||||||
|
self.assertIn('value="A"', html)
|
||||||
|
self.assertNotIn('checked="true"', html)
|
||||||
|
self.assertIn("Option A", html)
|
||||||
|
|
||||||
|
|
||||||
|
class PrimitiveWidgetsTest(SimpleTestCase):
|
||||||
|
def test_mixin_applies_widget_to_boolean_fields_only(self):
|
||||||
|
from django import forms
|
||||||
|
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
|
||||||
|
|
||||||
|
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
|
||||||
|
agree = forms.BooleanField(required=False)
|
||||||
|
name = forms.CharField(required=False)
|
||||||
|
|
||||||
|
form = DummyForm()
|
||||||
|
self.assertIsInstance(form.fields["agree"].widget, PrimitiveCheckboxWidget)
|
||||||
|
self.assertNotIsInstance(form.fields["name"].widget, PrimitiveCheckboxWidget)
|
||||||
|
|
||||||
|
def test_primitive_checkbox_widget_renders_headless(self):
|
||||||
|
from games.forms import PrimitiveCheckboxWidget
|
||||||
|
widget = PrimitiveCheckboxWidget()
|
||||||
|
html = widget.render(name="agree", value=True)
|
||||||
|
self.assertNotIn("<label", html)
|
||||||
|
self.assertIn("<input", html)
|
||||||
|
self.assertIn('type="checkbox"', html)
|
||||||
|
self.assertIn('name="agree"', html)
|
||||||
|
self.assertIn('checked="true"', html)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
|
|
||||||
def test_device_filter_bar(self):
|
def test_device_filter_bar(self):
|
||||||
from common.components import DeviceFilterBar
|
from common.components import DeviceFilterBar
|
||||||
|
|
||||||
html = str(
|
html = str(
|
||||||
DeviceFilterBar(
|
DeviceFilterBar(
|
||||||
filter_json="",
|
filter_json="",
|
||||||
@@ -200,6 +201,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
|
|
||||||
def test_platform_filter_bar(self):
|
def test_platform_filter_bar(self):
|
||||||
from common.components import PlatformFilterBar
|
from common.components import PlatformFilterBar
|
||||||
|
|
||||||
html = str(
|
html = str(
|
||||||
PlatformFilterBar(
|
PlatformFilterBar(
|
||||||
filter_json="",
|
filter_json="",
|
||||||
@@ -211,6 +213,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
|
|
||||||
def test_playevent_filter_bar(self):
|
def test_playevent_filter_bar(self):
|
||||||
from common.components import PlayEventFilterBar
|
from common.components import PlayEventFilterBar
|
||||||
|
|
||||||
html = str(
|
html = str(
|
||||||
PlayEventFilterBar(
|
PlayEventFilterBar(
|
||||||
filter_json="",
|
filter_json="",
|
||||||
@@ -219,3 +222,144 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||||
|
|
||||||
|
def test_game_filter_bar_has_new_widgets(self):
|
||||||
|
"""The expanded games FilterBar exposes platform_group, device, playevent_note,
|
||||||
|
purchase_type / purchase_ownership_type, plus count and aggregate-playtime
|
||||||
|
range sliders and the new boolean checkboxes."""
|
||||||
|
html = str(
|
||||||
|
FilterBar(
|
||||||
|
filter_json="",
|
||||||
|
preset_list_url="/l",
|
||||||
|
preset_save_url="/s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# New search-backed selects
|
||||||
|
self.assertIn('data-search-url="/api/devices/search"', html)
|
||||||
|
self.assertIn('data-search-url="/api/platforms/groups"', html)
|
||||||
|
# New enum selects (purchase type / ownership)
|
||||||
|
self.assertIn('data-name="purchase_type"', html)
|
||||||
|
self.assertIn('data-name="purchase_ownership_type"', html)
|
||||||
|
# Free-text widget for playevent notes (now StringFilter)
|
||||||
|
self.assertIn('name="filter-playevent_note"', html)
|
||||||
|
self.assertIn('name="filter-playevent_note-modifier"', html)
|
||||||
|
# New range slider input prefixes
|
||||||
|
self.assertIn('name="filter-purchase-count-min"', html)
|
||||||
|
self.assertIn('name="filter-playevent-count-min"', html)
|
||||||
|
self.assertIn('name="filter-manual-playtime-minutes-min"', html)
|
||||||
|
self.assertIn('name="filter-calculated-playtime-minutes-min"', html)
|
||||||
|
self.assertIn('name="filter-original-year-min"', html)
|
||||||
|
self.assertIn('name="filter-purchase-price-total-min"', html)
|
||||||
|
self.assertIn('name="filter-purchase-price-any-min"', html)
|
||||||
|
# New boolean checkboxes
|
||||||
|
self.assertIn('name="filter-purchase-refunded"', html)
|
||||||
|
self.assertIn('name="filter-purchase-infinite"', html)
|
||||||
|
self.assertIn('name="filter-session-emulated"', html)
|
||||||
|
# Removed boolean checkboxes
|
||||||
|
self.assertNotIn('name="filter-has-purchases"', html)
|
||||||
|
self.assertNotIn('name="filter-has-playevents"', html)
|
||||||
|
# Playtime label renamed
|
||||||
|
self.assertIn("Total playtime", html)
|
||||||
|
|
||||||
|
def test_purchase_filter_bar_renders_date_inputs(self):
|
||||||
|
"""PurchaseFilterBar surfaces date_purchased and date_refunded as
|
||||||
|
type=date input pairs with -min/-max naming."""
|
||||||
|
html = str(
|
||||||
|
PurchaseFilterBar(
|
||||||
|
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in (
|
||||||
|
"filter-date-purchased-min",
|
||||||
|
"filter-date-purchased-max",
|
||||||
|
"filter-date-refunded-min",
|
||||||
|
"filter-date-refunded-max",
|
||||||
|
):
|
||||||
|
self.assertIn(f'name="{name}"', html)
|
||||||
|
self.assertIn(f'id="{name}"', html)
|
||||||
|
# Inputs are native date pickers, not text.
|
||||||
|
self.assertIn('type="date"', html)
|
||||||
|
self.assertNoEscapedTags(html)
|
||||||
|
|
||||||
|
def test_purchase_filter_bar_prepopulates_dates_between(self):
|
||||||
|
"""A BETWEEN filter populates both date bounds via _parse_range."""
|
||||||
|
filter_json = json.dumps(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
html = str(
|
||||||
|
PurchaseFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url="/l",
|
||||||
|
preset_save_url="/s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
|
||||||
|
'value="2024-01-01"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
||||||
|
'value="2024-12-31"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_purchase_filter_bar_prepopulates_dates_single_bound(self):
|
||||||
|
"""A single-bound (GREATER_THAN) filter populates min only."""
|
||||||
|
filter_json = json.dumps(
|
||||||
|
{
|
||||||
|
"date_refunded": {
|
||||||
|
"value": "2024-06-01",
|
||||||
|
"modifier": "GREATER_THAN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
html = str(
|
||||||
|
PurchaseFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url="/l",
|
||||||
|
preset_save_url="/s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-refunded-min" id="filter-date-refunded-min" '
|
||||||
|
'value="2024-06-01"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
# Max input is still present but with empty value.
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-refunded-max" id="filter-date-refunded-max" value=""',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_boolean_fields_render_as_radio_groups(self):
|
||||||
|
"""Boolean fields must render as radio groups with True/False choices."""
|
||||||
|
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
|
||||||
|
|
||||||
|
# 1. Games Filter Bar
|
||||||
|
games_html = str(FilterBar(filter_json=""))
|
||||||
|
self.assertIn('type="radio"', games_html)
|
||||||
|
self.assertIn('name="filter-mastered"', games_html)
|
||||||
|
self.assertIn('value="true"', games_html)
|
||||||
|
self.assertIn('value="false"', games_html)
|
||||||
|
|
||||||
|
# 2. Session Filter Bar
|
||||||
|
session_html = str(SessionFilterBar(filter_json=""))
|
||||||
|
self.assertIn('type="radio"', session_html)
|
||||||
|
self.assertIn('name="filter-emulated"', session_html)
|
||||||
|
self.assertIn('value="true"', session_html)
|
||||||
|
self.assertIn('value="false"', session_html)
|
||||||
|
|
||||||
|
# 3. Purchase Filter Bar
|
||||||
|
purchase_html = str(PurchaseFilterBar(filter_json=""))
|
||||||
|
self.assertIn('type="radio"', purchase_html)
|
||||||
|
self.assertIn('name="filter-refunded"', purchase_html)
|
||||||
|
self.assertIn('value="true"', purchase_html)
|
||||||
|
self.assertIn('value="false"', purchase_html)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
from common.components.filters import _parse_bool, _parse_range
|
from common.components.filters import _parse_bool, _parse_range, _parse_bool_nullable
|
||||||
|
|
||||||
|
|
||||||
class ParseRangeTest(SimpleTestCase):
|
class ParseRangeTest(SimpleTestCase):
|
||||||
@@ -66,3 +66,23 @@ class ParseBoolTest(SimpleTestCase):
|
|||||||
|
|
||||||
def test_missing_value_in_field(self):
|
def test_missing_value_in_field(self):
|
||||||
self.assertFalse(_parse_bool({"field": {}}, "field"))
|
self.assertFalse(_parse_bool({"field": {}}, "field"))
|
||||||
|
|
||||||
|
|
||||||
|
class ParseBoolNullableTest(SimpleTestCase):
|
||||||
|
def test_missing_key(self):
|
||||||
|
self.assertIsNone(_parse_bool_nullable({}, "field"))
|
||||||
|
|
||||||
|
def test_null_value(self):
|
||||||
|
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
|
||||||
|
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
|
||||||
|
|
||||||
|
def test_boolean_values(self):
|
||||||
|
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
|
||||||
|
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
|
||||||
|
|
||||||
|
def test_string_values(self):
|
||||||
|
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field"))
|
||||||
|
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
||||||
|
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
||||||
|
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||||
|
|
||||||
|
|||||||
+472
-36
@@ -8,6 +8,7 @@ from django.db.models import Q
|
|||||||
from common.criteria import (
|
from common.criteria import (
|
||||||
BoolCriterion,
|
BoolCriterion,
|
||||||
ChoiceCriterion,
|
ChoiceCriterion,
|
||||||
|
DateCriterion,
|
||||||
IntCriterion,
|
IntCriterion,
|
||||||
Modifier,
|
Modifier,
|
||||||
MultiCriterion,
|
MultiCriterion,
|
||||||
@@ -37,10 +38,34 @@ class TestStringCriterion:
|
|||||||
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
|
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
|
||||||
assert c.to_q("name") == Q(name="zelda")
|
assert c.to_q("name") == Q(name="zelda")
|
||||||
|
|
||||||
|
def test_not_equals(self):
|
||||||
|
c = StringCriterion(value="zelda", modifier=Modifier.NOT_EQUALS)
|
||||||
|
assert c.to_q("name") == ~Q(name="zelda")
|
||||||
|
|
||||||
|
def test_includes(self):
|
||||||
|
c = StringCriterion(value="zelda", modifier=Modifier.INCLUDES)
|
||||||
|
assert c.to_q("name") == Q(name__icontains="zelda")
|
||||||
|
|
||||||
|
def test_excludes(self):
|
||||||
|
c = StringCriterion(value="zelda", modifier=Modifier.EXCLUDES)
|
||||||
|
assert c.to_q("name") == ~Q(name__icontains="zelda")
|
||||||
|
|
||||||
|
def test_matches_regex(self):
|
||||||
|
c = StringCriterion(value="zelda", modifier=Modifier.MATCHES_REGEX)
|
||||||
|
assert c.to_q("name") == Q(name__regex="zelda")
|
||||||
|
|
||||||
|
def test_not_matches_regex(self):
|
||||||
|
c = StringCriterion(value="zelda", modifier=Modifier.NOT_MATCHES_REGEX)
|
||||||
|
assert c.to_q("name") == ~Q(name__regex="zelda")
|
||||||
|
|
||||||
def test_is_null(self):
|
def test_is_null(self):
|
||||||
c = StringCriterion(value="", modifier=Modifier.IS_NULL)
|
c = StringCriterion(value="", modifier=Modifier.IS_NULL)
|
||||||
assert c.to_q("name") == Q(name__isnull=True)
|
assert c.to_q("name") == Q(name__isnull=True)
|
||||||
|
|
||||||
|
def test_not_null(self):
|
||||||
|
c = StringCriterion(value="", modifier=Modifier.NOT_NULL)
|
||||||
|
assert c.to_q("name") == Q(name__isnull=False)
|
||||||
|
|
||||||
|
|
||||||
class TestIntCriterion:
|
class TestIntCriterion:
|
||||||
def test_between(self):
|
def test_between(self):
|
||||||
@@ -535,7 +560,8 @@ class TestFilterBarRendering:
|
|||||||
|
|
||||||
def test_mastered_not_checked_by_default(self):
|
def test_mastered_not_checked_by_default(self):
|
||||||
html = str(FilterBar(filter_json=""))
|
html = str(FilterBar(filter_json=""))
|
||||||
assert 'checked="true"' not in html
|
assert 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||||
|
assert 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||||
|
|
||||||
def test_mastered_checked_when_filtered(self):
|
def test_mastered_checked_when_filtered(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -667,9 +693,15 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
# 1. Platform & Game
|
# 1. Platform & Game
|
||||||
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
|
plat, _ = Platform.objects.get_or_create(
|
||||||
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
|
name="Retro Console", group="Nintendo", icon="retro"
|
||||||
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
|
)
|
||||||
|
game, _ = Game.objects.get_or_create(
|
||||||
|
name="Super Mario World", defaults={"platform": plat, "status": "f"}
|
||||||
|
)
|
||||||
|
game2, _ = Game.objects.get_or_create(
|
||||||
|
name="Zelda", defaults={"platform": plat, "status": "u"}
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Device & Session
|
# 2. Device & Session
|
||||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||||
@@ -678,9 +710,13 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
s1 = Session.objects.create(
|
s1 = Session.objects.create(
|
||||||
game=game,
|
game=game,
|
||||||
device=dev,
|
device=dev,
|
||||||
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
timestamp_start=datetime.datetime(
|
||||||
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
duration_manual=timedelta(minutes=10)
|
),
|
||||||
|
timestamp_end=datetime.datetime(
|
||||||
|
2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
duration_manual=timedelta(minutes=10),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Purchase
|
# 3. Purchase
|
||||||
@@ -692,7 +728,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
price_currency="JPY",
|
price_currency="JPY",
|
||||||
converted_price=45.00,
|
converted_price=45.00,
|
||||||
converted_currency="USD",
|
converted_currency="USD",
|
||||||
needs_price_update=False
|
needs_price_update=False,
|
||||||
)
|
)
|
||||||
pur.games.add(game)
|
pur.games.add(game)
|
||||||
|
|
||||||
@@ -701,7 +737,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
game=game,
|
game=game,
|
||||||
started=datetime.date(2026, 6, 1),
|
started=datetime.date(2026, 6, 1),
|
||||||
ended=datetime.date(2026, 6, 2),
|
ended=datetime.date(2026, 6, 2),
|
||||||
note="Completed 100%"
|
note="Completed 100%",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -711,7 +747,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
"dev": dev,
|
"dev": dev,
|
||||||
"s1": s1,
|
"s1": s1,
|
||||||
"pur": pur,
|
"pur": pur,
|
||||||
"pe": pe
|
"pe": pe,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_device_filter_and_cross_entity(self):
|
def test_device_filter_and_cross_entity(self):
|
||||||
@@ -720,13 +756,15 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# Find devices that have sessions on "Super Mario World"
|
# Find devices that have sessions on "Super Mario World"
|
||||||
df = DeviceFilter.from_json({
|
df = DeviceFilter.from_json(
|
||||||
|
{
|
||||||
"session_filter": {
|
"session_filter": {
|
||||||
"game_filter": {
|
"game_filter": {
|
||||||
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
results = list(Device.objects.filter(df.to_q()))
|
results = list(Device.objects.filter(df.to_q()))
|
||||||
assert data["dev"] in results
|
assert data["dev"] in results
|
||||||
|
|
||||||
@@ -736,11 +774,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# Find platforms with games that are finished
|
# Find platforms with games that are finished
|
||||||
pf = PlatformFilter.from_json({
|
pf = PlatformFilter.from_json(
|
||||||
"game_filter": {
|
{"game_filter": {"status": {"value": ["f"], "modifier": "INCLUDES"}}}
|
||||||
"status": {"value": ["f"], "modifier": "INCLUDES"}
|
)
|
||||||
}
|
|
||||||
})
|
|
||||||
results = list(Platform.objects.filter(pf.to_q()))
|
results = list(Platform.objects.filter(pf.to_q()))
|
||||||
assert data["plat"] in results
|
assert data["plat"] in results
|
||||||
|
|
||||||
@@ -751,21 +787,21 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
# Test duration_total_minutes equals 40
|
# Test duration_total_minutes equals 40
|
||||||
sf_tot = SessionFilter.from_json({
|
sf_tot = SessionFilter.from_json(
|
||||||
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
|
{"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||||
|
|
||||||
# Test duration_manual_minutes equals 10
|
# Test duration_manual_minutes equals 10
|
||||||
sf_man = SessionFilter.from_json({
|
sf_man = SessionFilter.from_json(
|
||||||
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
|
{"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||||
|
|
||||||
# Test duration_calculated_minutes equals 30
|
# Test duration_calculated_minutes equals 30
|
||||||
sf_calc = SessionFilter.from_json({
|
sf_calc = SessionFilter.from_json(
|
||||||
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
|
{"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||||
|
|
||||||
def test_purchase_filter_new_fields(self):
|
def test_purchase_filter_new_fields(self):
|
||||||
@@ -774,11 +810,13 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
pf = PurchaseFilter.from_json({
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
"infinite": {"value": True, "modifier": "EQUALS"},
|
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||||
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||||
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
|
"converted_currency": {"value": "USD", "modifier": "EQUALS"},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||||
|
|
||||||
def test_game_filter_stats_and_existence(self):
|
def test_game_filter_stats_and_existence(self):
|
||||||
@@ -787,15 +825,413 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
# has_purchases = True
|
# purchase_count == 1 (replaces removed has_purchases boolean)
|
||||||
gf_pur = GameFilter.from_json({
|
gf_pur = GameFilter.from_json(
|
||||||
"has_purchases": {"value": True, "modifier": "EQUALS"}
|
{"purchase_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
|
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
|
||||||
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
|
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
|
||||||
|
|
||||||
# session_count = 1
|
# session_count = 1
|
||||||
gf_cnt = GameFilter.from_json({
|
gf_cnt = GameFilter.from_json(
|
||||||
"session_count": {"value": 1, "modifier": "EQUALS"}
|
{"session_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||||
|
|
||||||
|
def test_game_filter_purchase_count_range(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
|
||||||
|
# game has 1 purchase, game2 has 0
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{"purchase_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
|
assert data["game"] in results
|
||||||
|
assert data["game2"] not in results
|
||||||
|
|
||||||
|
def test_game_filter_playevent_count(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{"playevent_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
|
assert data["game"] in results
|
||||||
|
assert data["game2"] not in results
|
||||||
|
|
||||||
|
def test_game_filter_device(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{"device": {"value": [data["dev"].id], "modifier": "INCLUDES"}}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
|
assert data["game"] in results
|
||||||
|
assert data["game2"] not in results
|
||||||
|
|
||||||
|
def test_game_filter_platform_group(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{"platform_group": {"value": ["Nintendo"], "modifier": "INCLUDES"}}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
|
# both games are on the same Nintendo platform
|
||||||
|
assert data["game"] in results
|
||||||
|
assert data["game2"] in results
|
||||||
|
|
||||||
|
def test_game_filter_session_emulated(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game, Session
|
||||||
|
import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
Session.objects.create(
|
||||||
|
game=data["game2"],
|
||||||
|
device=data["dev"],
|
||||||
|
timestamp_start=datetime.datetime(
|
||||||
|
2026, 6, 2, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
timestamp_end=datetime.datetime(
|
||||||
|
2026, 6, 2, 12, 30, 0, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
duration_manual=timedelta(0),
|
||||||
|
emulated=True,
|
||||||
|
)
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{"session_emulated": {"value": True, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
|
assert data["game2"] in results
|
||||||
|
assert data["game"] not in results
|
||||||
|
|
||||||
|
def test_game_filter_purchase_refunded_and_infinite(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game, Purchase
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# data["pur"] is infinite=True, non-refunded.
|
||||||
|
gf_inf = GameFilter.from_json(
|
||||||
|
{"purchase_infinite": {"value": True, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
assert data["game"] in set(Game.objects.filter(gf_inf.to_q()))
|
||||||
|
assert data["game2"] not in set(Game.objects.filter(gf_inf.to_q()))
|
||||||
|
|
||||||
|
# Add a refunded purchase for game2.
|
||||||
|
refunded = Purchase.objects.create(
|
||||||
|
platform=data["plat"],
|
||||||
|
date_purchased=datetime.date(2026, 1, 1),
|
||||||
|
date_refunded=datetime.date(2026, 2, 1),
|
||||||
|
price=10.0,
|
||||||
|
price_currency="USD",
|
||||||
|
converted_price=10.0,
|
||||||
|
converted_currency="USD",
|
||||||
|
)
|
||||||
|
refunded.games.add(data["game2"])
|
||||||
|
gf_ref = GameFilter.from_json(
|
||||||
|
{"purchase_refunded": {"value": True, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf_ref.to_q()))
|
||||||
|
assert data["game2"] in results
|
||||||
|
assert data["game"] not in results
|
||||||
|
|
||||||
|
def test_game_filter_purchase_type_and_ownership(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# data["pur"] defaults to type=game, ownership_type=digital
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{"purchase_type": {"value": ["game"], "modifier": "INCLUDES"}}
|
||||||
|
)
|
||||||
|
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||||
|
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{"purchase_ownership_type": {"value": ["di"], "modifier": "INCLUDES"}}
|
||||||
|
)
|
||||||
|
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||||
|
|
||||||
|
def test_game_filter_purchase_price_any_and_total(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# data["pur"] has converted_price=45.00 linked to data["game"]
|
||||||
|
gf_any = GameFilter.from_json(
|
||||||
|
{
|
||||||
|
"purchase_price_any": {
|
||||||
|
"value": 40.0,
|
||||||
|
"value2": 50.0,
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf_any.to_q()))
|
||||||
|
assert data["game"] in results
|
||||||
|
assert data["game2"] not in results
|
||||||
|
|
||||||
|
gf_total = GameFilter.from_json(
|
||||||
|
{
|
||||||
|
"purchase_price_total": {
|
||||||
|
"value": 40.0,
|
||||||
|
"value2": 50.0,
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf_total.to_q()))
|
||||||
|
assert data["game"] in results
|
||||||
|
assert data["game2"] not in results
|
||||||
|
|
||||||
|
def test_game_filter_playevent_note_includes(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# data["pe"] has note="Completed 100%" on data["game"]
|
||||||
|
gf = GameFilter.from_json(
|
||||||
|
{
|
||||||
|
"playevent_note": {
|
||||||
|
"value": "Completed",
|
||||||
|
"modifier": "INCLUDES",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
|
assert data["game"] in results
|
||||||
|
assert data["game2"] not in results
|
||||||
|
|
||||||
|
def test_game_filter_manual_and_calculated_playtime(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# data["s1"] has 10 minutes manual + 30 minutes calculated
|
||||||
|
gf_manual = GameFilter.from_json(
|
||||||
|
{"manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
||||||
|
|
||||||
|
gf_calc = GameFilter.from_json(
|
||||||
|
{"calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateCriterion:
|
||||||
|
def test_equals(self):
|
||||||
|
c = DateCriterion(value="2025-06-01", modifier=Modifier.EQUALS)
|
||||||
|
assert c.to_q("date_purchased") == Q(date_purchased="2025-06-01")
|
||||||
|
|
||||||
|
def test_not_equals(self):
|
||||||
|
c = DateCriterion(value="2025-06-01", modifier=Modifier.NOT_EQUALS)
|
||||||
|
assert c.to_q("date_purchased") == ~Q(date_purchased="2025-06-01")
|
||||||
|
|
||||||
|
def test_greater_than(self):
|
||||||
|
c = DateCriterion(value="2025-06-01", modifier=Modifier.GREATER_THAN)
|
||||||
|
assert c.to_q("date_purchased") == Q(date_purchased__gt="2025-06-01")
|
||||||
|
|
||||||
|
def test_less_than(self):
|
||||||
|
c = DateCriterion(value="2025-06-01", modifier=Modifier.LESS_THAN)
|
||||||
|
assert c.to_q("date_purchased") == Q(date_purchased__lt="2025-06-01")
|
||||||
|
|
||||||
|
def test_between(self):
|
||||||
|
c = DateCriterion(
|
||||||
|
value="2025-01-01", value2="2025-12-31", modifier=Modifier.BETWEEN
|
||||||
|
)
|
||||||
|
assert c.to_q("date_purchased") == Q(
|
||||||
|
date_purchased__gte="2025-01-01", date_purchased__lte="2025-12-31"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_between_missing_value2_raises(self):
|
||||||
|
c = DateCriterion(value="2025-01-01", modifier=Modifier.BETWEEN)
|
||||||
|
with pytest.raises(ValueError, match="BETWEEN requires value2"):
|
||||||
|
c.to_q("date_purchased")
|
||||||
|
|
||||||
|
def test_not_between(self):
|
||||||
|
c = DateCriterion(
|
||||||
|
value="2025-01-01", value2="2025-12-31", modifier=Modifier.NOT_BETWEEN
|
||||||
|
)
|
||||||
|
assert c.to_q("date_purchased") == Q(date_purchased__lt="2025-01-01") | Q(
|
||||||
|
date_purchased__gt="2025-12-31"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_not_between_missing_value2_raises(self):
|
||||||
|
c = DateCriterion(value="2025-01-01", modifier=Modifier.NOT_BETWEEN)
|
||||||
|
with pytest.raises(ValueError, match="NOT_BETWEEN requires value2"):
|
||||||
|
c.to_q("date_purchased")
|
||||||
|
|
||||||
|
def test_is_null(self):
|
||||||
|
c = DateCriterion(value="", modifier=Modifier.IS_NULL)
|
||||||
|
assert c.to_q("date_refunded") == Q(date_refunded__isnull=True)
|
||||||
|
|
||||||
|
def test_not_null(self):
|
||||||
|
c = DateCriterion(value="", modifier=Modifier.NOT_NULL)
|
||||||
|
assert c.to_q("date_refunded") == Q(date_refunded__isnull=False)
|
||||||
|
|
||||||
|
def test_unsupported_modifier_raises(self):
|
||||||
|
c = DateCriterion(value="2025-06-01", modifier=Modifier.INCLUDES)
|
||||||
|
with pytest.raises(ValueError, match="Unsupported modifier"):
|
||||||
|
c.to_q("date_purchased")
|
||||||
|
|
||||||
|
def test_round_trip_json(self):
|
||||||
|
"""Dataclass → dict → dataclass survives unchanged for a full BETWEEN."""
|
||||||
|
original = DateCriterion(
|
||||||
|
value="2025-06-01", value2="2025-12-31", modifier=Modifier.BETWEEN
|
||||||
|
)
|
||||||
|
as_dict = original.to_json()
|
||||||
|
assert as_dict == {
|
||||||
|
"value": "2025-06-01",
|
||||||
|
"value2": "2025-12-31",
|
||||||
|
"modifier": Modifier.BETWEEN,
|
||||||
|
}
|
||||||
|
restored = DateCriterion.from_json(
|
||||||
|
{
|
||||||
|
"value": "2025-06-01",
|
||||||
|
"value2": "2025-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert restored == original
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurchaseFilterDates:
|
||||||
|
"""End-to-end: a PurchaseFilter built from JSON narrows the queryset
|
||||||
|
correctly across the two DateCriterion fields and composes with
|
||||||
|
BoolCriterion (is_refunded)."""
|
||||||
|
|
||||||
|
def _seed(self):
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from games.models import Platform, Purchase
|
||||||
|
|
||||||
|
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
|
||||||
|
early = Purchase.objects.create(
|
||||||
|
platform=platform, date_purchased=datetime.date(2024, 1, 15)
|
||||||
|
)
|
||||||
|
mid = Purchase.objects.create(
|
||||||
|
platform=platform,
|
||||||
|
date_purchased=datetime.date(2024, 6, 15),
|
||||||
|
date_refunded=datetime.date(2024, 7, 1),
|
||||||
|
)
|
||||||
|
late = Purchase.objects.create(
|
||||||
|
platform=platform, date_purchased=datetime.date(2025, 1, 15)
|
||||||
|
)
|
||||||
|
return {"early": early, "mid": mid, "late": late}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_date_purchased_between(self):
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["early"], seeded["mid"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_date_purchased_greater_than(self):
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-06-15",
|
||||||
|
"modifier": "GREATER_THAN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["late"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_date_refunded_is_null(self):
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{"date_refunded": {"value": "", "modifier": "IS_NULL"}}
|
||||||
|
)
|
||||||
|
results = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["early"], seeded["late"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_date_refunded_not_null(self):
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
|
||||||
|
)
|
||||||
|
results = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["mid"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_purchased_between_and_refunded_not_null(self):
|
||||||
|
"""AND-composition: only the mid purchase satisfies both."""
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
},
|
||||||
|
"date_refunded": {"value": "", "modifier": "NOT_NULL"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = set(Purchase.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["mid"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_purchase_filter_json_round_trip(self):
|
||||||
|
"""PurchaseFilter with both DateCriterion fields and is_refunded
|
||||||
|
survives a json → object → json round-trip — confirms
|
||||||
|
DateCriterion is dispatched correctly by OperatorFilter.from_json
|
||||||
|
via the criterion_types lookup."""
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
},
|
||||||
|
"date_refunded": {"value": "", "modifier": "NOT_NULL"},
|
||||||
|
"is_refunded": {"value": True, "modifier": "EQUALS"},
|
||||||
|
}
|
||||||
|
pf = PurchaseFilter.from_json(payload)
|
||||||
|
assert isinstance(pf.date_purchased, DateCriterion)
|
||||||
|
assert isinstance(pf.date_refunded, DateCriterion)
|
||||||
|
# round-trip back out
|
||||||
|
out = pf.to_json()
|
||||||
|
assert out["date_purchased"]["value"] == "2024-01-01"
|
||||||
|
assert out["date_purchased"]["value2"] == "2024-12-31"
|
||||||
|
assert out["date_purchased"]["modifier"] == Modifier.BETWEEN
|
||||||
|
assert out["date_refunded"]["modifier"] == Modifier.NOT_NULL
|
||||||
|
|||||||
@@ -60,3 +60,16 @@ class PathWorksTest(TestCase):
|
|||||||
def test_list_purchases_returns_200(self):
|
def test_list_purchases_returns_200(self):
|
||||||
response = self.client.get(reverse("games:list_purchases"))
|
response = self.client.get(reverse("games:list_purchases"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_platform_groups_api_returns_200(self):
|
||||||
|
# Distinct platform groups are returned as string-valued options.
|
||||||
|
Platform.objects.create(name="Switch", icon="switch", group="Nintendo")
|
||||||
|
response = self.client.get("/api/platforms/groups")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
groups = {item["value"] for item in body}
|
||||||
|
self.assertIn("Nintendo", groups)
|
||||||
|
|
||||||
|
filtered = self.client.get("/api/platforms/groups?q=nin")
|
||||||
|
self.assertEqual(filtered.status_code, 200)
|
||||||
|
self.assertEqual({item["value"] for item in filtered.json()}, {"Nintendo"})
|
||||||
|
|||||||
@@ -290,3 +290,152 @@ class RenderedPagesTest(TestCase):
|
|||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
# The Python builder emits well-formed, balanced markup.
|
# The Python builder emits well-formed, balanced markup.
|
||||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseListDateFilterTest(TestCase):
|
||||||
|
"""End-to-end: GET /tracker/purchase/list?filter=… narrows the rendered
|
||||||
|
list and pre-fills the date inputs from the URL filter.
|
||||||
|
|
||||||
|
Replaces the manual curl smoke that earlier verified the same path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
username="datetester", email="dt@example.com", password="testpass"
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.platform = Platform.objects.create(name="DateP", icon="datep")
|
||||||
|
# Markers are placed on the Game name because LinkedPurchase renders
|
||||||
|
# the linked game's name (purchase.name doesn't surface in the list row).
|
||||||
|
early_game = Game.objects.create(name="EARLY-MARKER", platform=self.platform)
|
||||||
|
mid_game = Game.objects.create(name="MID-MARKER", platform=self.platform)
|
||||||
|
late_game = Game.objects.create(name="LATE-MARKER", platform=self.platform)
|
||||||
|
self.early = Purchase.objects.create(
|
||||||
|
platform=self.platform, date_purchased=datetime.date(2024, 1, 15)
|
||||||
|
)
|
||||||
|
self.early.games.add(early_game)
|
||||||
|
self.mid = Purchase.objects.create(
|
||||||
|
platform=self.platform,
|
||||||
|
date_purchased=datetime.date(2024, 6, 15),
|
||||||
|
date_refunded=datetime.date(2024, 7, 1),
|
||||||
|
)
|
||||||
|
self.mid.games.add(mid_game)
|
||||||
|
self.late = Purchase.objects.create(
|
||||||
|
platform=self.platform, date_purchased=datetime.date(2025, 1, 15)
|
||||||
|
)
|
||||||
|
self.late.games.add(late_game)
|
||||||
|
|
||||||
|
def _get(self, filter_obj=None, raw_filter=None):
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
url = reverse("games:list_purchases")
|
||||||
|
if raw_filter is not None:
|
||||||
|
return self.client.get(url, {"filter": raw_filter})
|
||||||
|
if filter_obj is not None:
|
||||||
|
return self.client.get(url, {"filter": json.dumps(filter_obj)})
|
||||||
|
return self.client.get(url)
|
||||||
|
|
||||||
|
def test_unfiltered_lists_all_three(self):
|
||||||
|
html = self._get().content.decode()
|
||||||
|
self.assertEqual(html.count("EARLY-MARKER"), 1)
|
||||||
|
self.assertEqual(html.count("MID-MARKER"), 1)
|
||||||
|
self.assertEqual(html.count("LATE-MARKER"), 1)
|
||||||
|
|
||||||
|
def test_date_purchased_between_narrows_and_prepopulates(self):
|
||||||
|
"""BETWEEN 2024-01-01..2024-12-31 → only early + mid; both date
|
||||||
|
inputs pre-filled with the filter bounds."""
|
||||||
|
response = self._get(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertIn("EARLY-MARKER", html)
|
||||||
|
self.assertIn("MID-MARKER", html)
|
||||||
|
self.assertNotIn("LATE-MARKER", html)
|
||||||
|
# Pre-populated date inputs round-trip the filter bounds.
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
|
||||||
|
'value="2024-01-01"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
||||||
|
'value="2024-12-31"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_date_purchased_greater_than_single_bound(self):
|
||||||
|
"""GREATER_THAN populates min only, leaves max blank."""
|
||||||
|
response = self._get(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-06-15",
|
||||||
|
"modifier": "GREATER_THAN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertNotIn("EARLY-MARKER", html)
|
||||||
|
self.assertNotIn("MID-MARKER", html)
|
||||||
|
self.assertIn("LATE-MARKER", html)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
|
||||||
|
'value="2024-06-15"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
||||||
|
'value=""',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_date_refunded_not_null(self):
|
||||||
|
response = self._get(
|
||||||
|
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertNotIn("EARLY-MARKER", html)
|
||||||
|
self.assertIn("MID-MARKER", html)
|
||||||
|
self.assertNotIn("LATE-MARKER", html)
|
||||||
|
|
||||||
|
def test_combined_dates_and_is_refunded(self):
|
||||||
|
"""date_purchased BETWEEN 2024 AND date_refunded NOT_NULL → only the
|
||||||
|
mid purchase. Confirms AND-composition through the view layer."""
|
||||||
|
response = self._get(
|
||||||
|
{
|
||||||
|
"date_purchased": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
},
|
||||||
|
"date_refunded": {"value": "", "modifier": "NOT_NULL"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertNotIn("EARLY-MARKER", html)
|
||||||
|
self.assertIn("MID-MARKER", html)
|
||||||
|
self.assertNotIn("LATE-MARKER", html)
|
||||||
|
|
||||||
|
def test_malformed_json_filter_falls_back_to_unfiltered(self):
|
||||||
|
"""parse_purchase_filter returns None on bad JSON → view ignores
|
||||||
|
the filter and renders the full list (no 500)."""
|
||||||
|
response = self._get(raw_filter="this is not json")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.content.decode()
|
||||||
|
# All three purchases are present, same as the unfiltered baseline.
|
||||||
|
self.assertIn("EARLY-MARKER", html)
|
||||||
|
self.assertIn("MID-MARKER", html)
|
||||||
|
self.assertIn("LATE-MARKER", html)
|
||||||
|
|||||||
Reference in New Issue
Block a user