Compare commits
26 Commits
main
...
9bf7215125
| 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
|
|||
|
89c9ff6367
|
|||
|
5887febbb7
|
@@ -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,10 +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 (
|
||||||
|
DeviceFilterBar,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
|
PlatformFilterBar,
|
||||||
|
PlayEventFilterBar,
|
||||||
PurchaseFilterBar,
|
PurchaseFilterBar,
|
||||||
SessionFilterBar,
|
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",
|
||||||
@@ -76,6 +85,7 @@ __all__ = [
|
|||||||
"AddForm",
|
"AddForm",
|
||||||
"Button",
|
"Button",
|
||||||
"ButtonGroup",
|
"ButtonGroup",
|
||||||
|
"Checkbox",
|
||||||
"CsrfInput",
|
"CsrfInput",
|
||||||
"Div",
|
"Div",
|
||||||
"ExternalScript",
|
"ExternalScript",
|
||||||
@@ -87,6 +97,7 @@ __all__ = [
|
|||||||
"Pill",
|
"Pill",
|
||||||
"Popover",
|
"Popover",
|
||||||
"PopoverTruncated",
|
"PopoverTruncated",
|
||||||
|
"Radio",
|
||||||
"SearchField",
|
"SearchField",
|
||||||
"DEFAULT_PREFETCH",
|
"DEFAULT_PREFETCH",
|
||||||
"FilterSelect",
|
"FilterSelect",
|
||||||
@@ -115,4 +126,8 @@ __all__ = [
|
|||||||
"FilterBar",
|
"FilterBar",
|
||||||
"PurchaseFilterBar",
|
"PurchaseFilterBar",
|
||||||
"SessionFilterBar",
|
"SessionFilterBar",
|
||||||
|
"DeviceFilterBar",
|
||||||
|
"PlatformFilterBar",
|
||||||
|
"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],
|
||||||
|
|||||||
+702
-95
File diff suppressed because it is too large
Load Diff
+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,662 @@
|
|||||||
|
# Comprehensive Filters 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:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
|
||||||
|
|
||||||
|
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
|
||||||
|
|
||||||
|
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/filters.py`
|
||||||
|
- Test: `tests/test_filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
|
||||||
|
|
||||||
|
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Insert new filter imports and classes in games/filters.py
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceFilter(OperatorFilter):
|
||||||
|
"""Filter for the Device model."""
|
||||||
|
|
||||||
|
AND: DeviceFilter | None = None
|
||||||
|
OR: DeviceFilter | None = None
|
||||||
|
NOT: DeviceFilter | None = None
|
||||||
|
|
||||||
|
name: StringCriterion | None = None
|
||||||
|
type: ChoiceCriterion | None = None
|
||||||
|
created_at: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
search: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity: Devices that have sessions matching these criteria
|
||||||
|
session_filter: SessionFilter | None = None
|
||||||
|
|
||||||
|
def to_q(self) -> Q:
|
||||||
|
q = Q()
|
||||||
|
|
||||||
|
if self.name is not None:
|
||||||
|
q &= self.name.to_q("name")
|
||||||
|
if self.type is not None:
|
||||||
|
q &= self.type.to_q("type")
|
||||||
|
if self.created_at is not None:
|
||||||
|
q &= self.created_at.to_q("created_at")
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if self.search is not None and self.search.value:
|
||||||
|
search_q = (
|
||||||
|
Q(name__icontains=self.search.value)
|
||||||
|
| Q(type__icontains=self.search.value)
|
||||||
|
)
|
||||||
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
|
search_q = ~search_q
|
||||||
|
q &= search_q
|
||||||
|
|
||||||
|
# Cross-entity filter: session_filter
|
||||||
|
if self.session_filter is not None:
|
||||||
|
from games.models import Session
|
||||||
|
session_q = self.session_filter.to_q()
|
||||||
|
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
sub = self.sub_filter()
|
||||||
|
if sub is not None:
|
||||||
|
if self.AND is not None:
|
||||||
|
q &= sub.to_q()
|
||||||
|
elif self.OR is not None:
|
||||||
|
q |= sub.to_q()
|
||||||
|
elif self.NOT is not None:
|
||||||
|
q &= ~sub.to_q()
|
||||||
|
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlatformFilter(OperatorFilter):
|
||||||
|
"""Filter for the Platform model."""
|
||||||
|
|
||||||
|
AND: PlatformFilter | None = None
|
||||||
|
OR: PlatformFilter | None = None
|
||||||
|
NOT: PlatformFilter | None = None
|
||||||
|
|
||||||
|
name: StringCriterion | None = None
|
||||||
|
group: StringCriterion | None = None
|
||||||
|
icon: StringCriterion | None = None
|
||||||
|
created_at: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
search: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity
|
||||||
|
game_filter: GameFilter | None = None
|
||||||
|
purchase_filter: PurchaseFilter | None = None
|
||||||
|
|
||||||
|
def to_q(self) -> Q:
|
||||||
|
q = Q()
|
||||||
|
|
||||||
|
if self.name is not None:
|
||||||
|
q &= self.name.to_q("name")
|
||||||
|
if self.group is not None:
|
||||||
|
q &= self.group.to_q("group")
|
||||||
|
if self.icon is not None:
|
||||||
|
q &= self.icon.to_q("icon")
|
||||||
|
if self.created_at is not None:
|
||||||
|
q &= self.created_at.to_q("created_at")
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if self.search is not None and self.search.value:
|
||||||
|
search_q = (
|
||||||
|
Q(name__icontains=self.search.value)
|
||||||
|
| Q(group__icontains=self.search.value)
|
||||||
|
)
|
||||||
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
|
search_q = ~search_q
|
||||||
|
q &= search_q
|
||||||
|
|
||||||
|
# Cross-entity filter: game_filter
|
||||||
|
if self.game_filter is not None:
|
||||||
|
from games.models import Game
|
||||||
|
game_q = self.game_filter.to_q()
|
||||||
|
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
# Cross-entity filter: purchase_filter
|
||||||
|
if self.purchase_filter is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
purchase_q = self.purchase_filter.to_q()
|
||||||
|
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
sub = self.sub_filter()
|
||||||
|
if sub is not None:
|
||||||
|
if self.AND is not None:
|
||||||
|
q &= sub.to_q()
|
||||||
|
elif self.OR is not None:
|
||||||
|
q |= sub.to_q()
|
||||||
|
elif self.NOT is not None:
|
||||||
|
q &= ~sub.to_q()
|
||||||
|
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayEventFilter(OperatorFilter):
|
||||||
|
"""Filter for the PlayEvent model."""
|
||||||
|
|
||||||
|
AND: PlayEventFilter | None = None
|
||||||
|
OR: PlayEventFilter | None = None
|
||||||
|
NOT: PlayEventFilter | None = None
|
||||||
|
|
||||||
|
game: MultiCriterion | None = None # filters on game_id
|
||||||
|
started: StringCriterion | None = None # date string
|
||||||
|
ended: StringCriterion | None = None # date string
|
||||||
|
days_to_finish: IntCriterion | None = None
|
||||||
|
note: StringCriterion | None = None
|
||||||
|
created_at: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
search: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity: PlayEvents for games matching these criteria
|
||||||
|
game_filter: GameFilter | None = None
|
||||||
|
|
||||||
|
def to_q(self) -> Q:
|
||||||
|
q = Q()
|
||||||
|
|
||||||
|
if self.game is not None:
|
||||||
|
q &= self.game.to_q("game_id")
|
||||||
|
if self.started is not None:
|
||||||
|
q &= self.started.to_q("started")
|
||||||
|
if self.ended is not None:
|
||||||
|
q &= self.ended.to_q("ended")
|
||||||
|
if self.days_to_finish is not None:
|
||||||
|
q &= self.days_to_finish.to_q("days_to_finish")
|
||||||
|
if self.note is not None:
|
||||||
|
q &= self.note.to_q("note")
|
||||||
|
if self.created_at is not None:
|
||||||
|
q &= self.created_at.to_q("created_at")
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if self.search is not None and self.search.value:
|
||||||
|
search_q = (
|
||||||
|
Q(game__name__icontains=self.search.value)
|
||||||
|
| Q(note__icontains=self.search.value)
|
||||||
|
)
|
||||||
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
|
search_q = ~search_q
|
||||||
|
q &= search_q
|
||||||
|
|
||||||
|
# Cross-entity filter: game_filter
|
||||||
|
if self.game_filter is not None:
|
||||||
|
from games.models import Game
|
||||||
|
game_q = self.game_filter.to_q()
|
||||||
|
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||||
|
q &= Q(game_id__in=matching_ids)
|
||||||
|
|
||||||
|
sub = self.sub_filter()
|
||||||
|
if sub is not None:
|
||||||
|
if self.AND is not None:
|
||||||
|
q &= sub.to_q()
|
||||||
|
elif self.OR is not None:
|
||||||
|
q |= sub.to_q()
|
||||||
|
elif self.NOT is not None:
|
||||||
|
q &= ~sub.to_q()
|
||||||
|
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
# Add to convenience helpers section:
|
||||||
|
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||||
|
return filter_from_json(DeviceFilter, json_str)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||||
|
return filter_from_json(PlatformFilter, json_str)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||||
|
return filter_from_json(PlayEventFilter, json_str)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run existing tests to verify everything compiles**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filters.py -v`
|
||||||
|
Expected: All existing tests PASS without issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/filters.py:SessionFilter`
|
||||||
|
- Test: `tests/test_filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
|
||||||
|
|
||||||
|
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
|
||||||
|
|
||||||
|
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inside SessionFilter class:
|
||||||
|
duration_total_minutes: IntCriterion | None = None
|
||||||
|
duration_manual_minutes: IntCriterion | None = None
|
||||||
|
duration_calculated_minutes: IntCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity: sessions for devices matching these criteria
|
||||||
|
device_filter: DeviceFilter | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Helper inside SessionFilter or refactored:
|
||||||
|
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||||
|
from datetime import timedelta
|
||||||
|
q = Q()
|
||||||
|
td_val = timedelta(minutes=c.value)
|
||||||
|
m = c.modifier
|
||||||
|
if m == Modifier.EQUALS:
|
||||||
|
q &= Q(
|
||||||
|
**{
|
||||||
|
f"{field}__gte": td_val,
|
||||||
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif m == Modifier.NOT_EQUALS:
|
||||||
|
q &= ~Q(
|
||||||
|
**{
|
||||||
|
f"{field}__gte": td_val,
|
||||||
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif m == Modifier.GREATER_THAN:
|
||||||
|
q &= Q(**{f"{field}__gt": td_val})
|
||||||
|
elif m == Modifier.LESS_THAN:
|
||||||
|
q &= Q(**{f"{field}__lt": td_val})
|
||||||
|
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||||
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
|
q &= Q(**{f"{field}__gte": lo, f"{f_field}__lte": hi})
|
||||||
|
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||||
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
|
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||||
|
elif m == Modifier.IS_NULL:
|
||||||
|
q &= Q(**{f"{field}": timedelta(0)})
|
||||||
|
elif m == Modifier.NOT_NULL:
|
||||||
|
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||||
|
return q
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `to_q()` inside `SessionFilter`:
|
||||||
|
```python
|
||||||
|
if self.duration_total_minutes is not None:
|
||||||
|
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
||||||
|
if self.duration_manual_minutes is not None:
|
||||||
|
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||||
|
if self.duration_calculated_minutes is not None:
|
||||||
|
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
|
||||||
|
|
||||||
|
# Cross-entity filter: device_filter
|
||||||
|
if self.device_filter is not None:
|
||||||
|
from games.models import Device
|
||||||
|
device_q = self.device_filter.to_q()
|
||||||
|
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||||
|
q &= Q(device_id__in=matching_ids)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify compiles correctly**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filters.py -v`
|
||||||
|
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/filters.py:PurchaseFilter`
|
||||||
|
- Test: `tests/test_filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
|
||||||
|
|
||||||
|
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inside PurchaseFilter class:
|
||||||
|
infinite: BoolCriterion | None = None
|
||||||
|
needs_price_update: BoolCriterion | None = None
|
||||||
|
converted_currency: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity
|
||||||
|
platform_filter: PlatformFilter | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `to_q()` inside `PurchaseFilter`:
|
||||||
|
```python
|
||||||
|
if self.infinite is not None:
|
||||||
|
q &= self.infinite.to_q("infinite")
|
||||||
|
if self.needs_price_update is not None:
|
||||||
|
q &= self.needs_price_update.to_q("needs_price_update")
|
||||||
|
if self.converted_currency is not None:
|
||||||
|
q &= self.converted_currency.to_q("converted_currency")
|
||||||
|
|
||||||
|
# Cross-entity filter: platform_filter
|
||||||
|
if self.platform_filter is not None:
|
||||||
|
from games.models import Platform
|
||||||
|
platform_q = self.platform_filter.to_q()
|
||||||
|
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||||
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify test suite continues to pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filters.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/filters.py:GameFilter`
|
||||||
|
- Test: `tests/test_filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
|
||||||
|
|
||||||
|
Add fields and cross-entity filters to `GameFilter`:
|
||||||
|
```python
|
||||||
|
# Inside GameFilter class:
|
||||||
|
has_purchases: BoolCriterion | None = None
|
||||||
|
has_playevents: BoolCriterion | None = None
|
||||||
|
session_count: IntCriterion | None = None
|
||||||
|
session_average: IntCriterion | None = None # average in minutes
|
||||||
|
|
||||||
|
# Cross-entity filters
|
||||||
|
session_filter: SessionFilter | None = None
|
||||||
|
purchase_filter: PurchaseFilter | None = None
|
||||||
|
playevent_filter: PlayEventFilter | None = None
|
||||||
|
platform_filter: PlatformFilter | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `to_q()` inside `GameFilter`.
|
||||||
|
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.has_purchases is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
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:
|
||||||
|
from games.models import Game
|
||||||
|
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)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.session_average is not None:
|
||||||
|
from games.models import Game, Session
|
||||||
|
from django.db.models import Avg, F, ExpressionWrapper, DurationField
|
||||||
|
# Compute average session total duration.
|
||||||
|
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
|
||||||
|
# To match the criterion easily, we can filter Game objects using 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)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
# Cross-entity filters
|
||||||
|
if self.session_filter is not None:
|
||||||
|
from games.models import Session
|
||||||
|
session_q = self.session_filter.to_q()
|
||||||
|
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.purchase_filter is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
purchase_q = self.purchase_filter.to_q()
|
||||||
|
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.playevent_filter is not None:
|
||||||
|
from games.models import PlayEvent
|
||||||
|
playevent_q = self.playevent_filter.to_q()
|
||||||
|
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.platform_filter is not None:
|
||||||
|
from games.models import Platform
|
||||||
|
platform_q = self.platform_filter.to_q()
|
||||||
|
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||||
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
|
||||||
|
```python
|
||||||
|
@staticmethod
|
||||||
|
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||||
|
from datetime import timedelta
|
||||||
|
m = c.modifier
|
||||||
|
td_val = timedelta(minutes=c.value)
|
||||||
|
|
||||||
|
if m == Modifier.EQUALS:
|
||||||
|
return Q(
|
||||||
|
**{
|
||||||
|
f"{field}__gte": td_val,
|
||||||
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if m == Modifier.NOT_EQUALS:
|
||||||
|
return ~Q(
|
||||||
|
**{
|
||||||
|
f"{field}__gte": td_val,
|
||||||
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if m == Modifier.GREATER_THAN:
|
||||||
|
return Q(**{f"{field}__gt": td_val})
|
||||||
|
if m == Modifier.LESS_THAN:
|
||||||
|
return Q(**{f"{field}__lt": td_val})
|
||||||
|
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||||
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
|
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||||
|
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||||
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
|
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||||
|
if m == Modifier.IS_NULL:
|
||||||
|
return Q(**{f"{field}": timedelta(0)})
|
||||||
|
if m == Modifier.NOT_NULL:
|
||||||
|
return ~Q(**{f"{field}": timedelta(0)})
|
||||||
|
return Q()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
|
||||||
|
```python
|
||||||
|
@staticmethod
|
||||||
|
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||||
|
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
|
||||||
|
|
||||||
|
Add comprehensive test cases inside `tests/test_filters.py` covering:
|
||||||
|
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
|
||||||
|
- Session total vs manual vs calculated duration filters.
|
||||||
|
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
|
||||||
|
- Device, Platform, and PlayEvent specific filters.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add test class at the end of tests/test_filters.py:
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestExpandedFiltersAgainstDB:
|
||||||
|
def _setup_entities(self):
|
||||||
|
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
|
||||||
|
import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# 1. Platform & Game
|
||||||
|
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
|
||||||
|
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
|
||||||
|
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||||
|
|
||||||
|
# Session 1: total 40 minutes (30 calc, 10 manual)
|
||||||
|
s1 = Session.objects.create(
|
||||||
|
game=game,
|
||||||
|
device=dev,
|
||||||
|
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||||
|
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
||||||
|
duration_manual=timedelta(minutes=10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Purchase
|
||||||
|
pur = Purchase.objects.create(
|
||||||
|
platform=plat,
|
||||||
|
date_purchased=datetime.date(2026, 1, 1),
|
||||||
|
infinite=True,
|
||||||
|
price=49.99,
|
||||||
|
price_currency="JPY",
|
||||||
|
converted_price=45.00,
|
||||||
|
converted_currency="USD",
|
||||||
|
needs_price_update=False
|
||||||
|
)
|
||||||
|
pur.games.add(game)
|
||||||
|
|
||||||
|
# 4. PlayEvent
|
||||||
|
pe = PlayEvent.objects.create(
|
||||||
|
game=game,
|
||||||
|
started=datetime.date(2026, 6, 1),
|
||||||
|
ended=datetime.date(2026, 6, 2),
|
||||||
|
note="Completed 100%"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plat": plat,
|
||||||
|
"game": game,
|
||||||
|
"game2": game2,
|
||||||
|
"dev": dev,
|
||||||
|
"s1": s1,
|
||||||
|
"pur": pur,
|
||||||
|
"pe": pe
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_device_filter_and_cross_entity(self):
|
||||||
|
from games.filters import DeviceFilter, SessionFilter
|
||||||
|
from games.models import Device
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# Find devices that have sessions on "Super Mario World"
|
||||||
|
df = DeviceFilter.from_json({
|
||||||
|
"session_filter": {
|
||||||
|
"game_filter": {
|
||||||
|
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
results = list(Device.objects.filter(df.to_q()))
|
||||||
|
assert data["dev"] in results
|
||||||
|
|
||||||
|
def test_platform_filter_and_cross_entity(self):
|
||||||
|
from games.filters import PlatformFilter, GameFilter
|
||||||
|
from games.models import Platform
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# Find platforms with games that are finished
|
||||||
|
pf = PlatformFilter.from_json({
|
||||||
|
"game_filter": {
|
||||||
|
"status": {"value": ["f"], "modifier": "INCLUDES"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
results = list(Platform.objects.filter(pf.to_q()))
|
||||||
|
assert data["plat"] in results
|
||||||
|
|
||||||
|
def test_session_filter_duration_splits(self):
|
||||||
|
from games.filters import SessionFilter
|
||||||
|
from games.models import Session
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
|
||||||
|
# Test duration_total_minutes equals 40
|
||||||
|
sf_tot = SessionFilter.from_json({
|
||||||
|
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
|
||||||
|
})
|
||||||
|
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||||
|
|
||||||
|
# Test duration_manual_minutes equals 10
|
||||||
|
sf_man = SessionFilter.from_json({
|
||||||
|
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
|
||||||
|
})
|
||||||
|
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||||
|
|
||||||
|
# Test duration_calculated_minutes equals 30
|
||||||
|
sf_calc = SessionFilter.from_json({
|
||||||
|
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
|
||||||
|
})
|
||||||
|
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||||
|
|
||||||
|
def test_purchase_filter_new_fields(self):
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
|
||||||
|
pf = PurchaseFilter.from_json({
|
||||||
|
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||||
|
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||||
|
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
|
||||||
|
})
|
||||||
|
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||||
|
|
||||||
|
def test_game_filter_stats_and_existence(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
|
||||||
|
# has_purchases = True
|
||||||
|
gf_pur = GameFilter.from_json({
|
||||||
|
"has_purchases": {"value": True, "modifier": "EQUALS"}
|
||||||
|
})
|
||||||
|
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()))
|
||||||
|
|
||||||
|
# session_count = 1
|
||||||
|
gf_cnt = GameFilter.from_json({
|
||||||
|
"session_count": {"value": 1, "modifier": "EQUALS"}
|
||||||
|
})
|
||||||
|
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all unit tests to confirm success**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filters.py -v`
|
||||||
|
Expected: ALL tests pass perfectly.
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
# Frontend Filters 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:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
|
||||||
|
|
||||||
|
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
|
||||||
|
|
||||||
|
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Update existing FilterBars in `common/components/filters.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `common/components/filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add new fields to GameFilterBar**
|
||||||
|
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inside common/components/filters.py: FilterBar()
|
||||||
|
|
||||||
|
# Parse new values
|
||||||
|
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_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
||||||
|
|
||||||
|
# Add components to fields:
|
||||||
|
# 1. Under status and platform, add the checkboxes for purchases/playevents
|
||||||
|
# 2. Add RangeSliders for session count and average
|
||||||
|
```
|
||||||
|
|
||||||
|
Code change to apply in `FilterBar`:
|
||||||
|
```python
|
||||||
|
fields = [
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
|
children=[
|
||||||
|
_filter_field(
|
||||||
|
"Status",
|
||||||
|
_enum_filter(
|
||||||
|
"status",
|
||||||
|
status_options,
|
||||||
|
status_choice,
|
||||||
|
nullable=not Game._meta.get_field("status").has_default(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Platform",
|
||||||
|
_model_filter(
|
||||||
|
"platform",
|
||||||
|
platform_choice,
|
||||||
|
search_url="/api/platforms/search",
|
||||||
|
nullable=Game._meta.get_field("platform").null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
label="Year",
|
||||||
|
input_name_prefix="filter-year",
|
||||||
|
min_value=year_min,
|
||||||
|
max_value=year_max,
|
||||||
|
range_min=year_range_min,
|
||||||
|
range_max=year_range_max,
|
||||||
|
min_placeholder="e.g. 2020",
|
||||||
|
max_placeholder="e.g. 2024",
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
label="Playtime",
|
||||||
|
input_name_prefix="filter-playtime",
|
||||||
|
min_value=playtime_min,
|
||||||
|
max_value=playtime_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=playtime_range_max,
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 1",
|
||||||
|
max_placeholder="e.g. 100",
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
label="Session Count",
|
||||||
|
input_name_prefix="filter-session-count",
|
||||||
|
min_value=session_count_min,
|
||||||
|
max_value=session_count_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=100,
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 1",
|
||||||
|
max_placeholder="e.g. 50",
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
label="Average Session Duration (mins)",
|
||||||
|
input_name_prefix="filter-session-average",
|
||||||
|
min_value=session_avg_min,
|
||||||
|
max_value=session_avg_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=240,
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 10",
|
||||||
|
max_placeholder="e.g. 120",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
|
||||||
|
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inside common/components/filters.py: SessionFilterBar()
|
||||||
|
|
||||||
|
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||||
|
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||||
|
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
||||||
|
|
||||||
|
# Inside fields array, replace RangeSlider "Duration" with:
|
||||||
|
RangeSlider(
|
||||||
|
label="Total Duration (mins)",
|
||||||
|
input_name_prefix="filter-duration-total-minutes",
|
||||||
|
min_value=dur_tot_min,
|
||||||
|
max_value=dur_tot_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=duration_range_max * 60, # Range sliders use minutes now
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 30",
|
||||||
|
max_placeholder="e.g. 180",
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
label="Manual Duration (mins)",
|
||||||
|
input_name_prefix="filter-duration-manual-minutes",
|
||||||
|
min_value=dur_man_min,
|
||||||
|
max_value=dur_man_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=240,
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 10",
|
||||||
|
max_placeholder="e.g. 120",
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
label="Calculated Duration (mins)",
|
||||||
|
input_name_prefix="filter-duration-calculated-minutes",
|
||||||
|
min_value=dur_calc_min,
|
||||||
|
max_value=dur_calc_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=duration_range_max * 60,
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 30",
|
||||||
|
max_placeholder="e.g. 180",
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
|
||||||
|
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inside common/components/filters.py: PurchaseFilterBar()
|
||||||
|
|
||||||
|
infinite_value = _parse_bool(existing, "infinite")
|
||||||
|
needs_price_update_value = _parse_bool(existing, "needs_price_update")
|
||||||
|
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
||||||
|
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
|
||||||
|
|
||||||
|
# Expand fields component array with:
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[("class", "flex 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
Add currency text filters (as primitive `Input` controls for string criteria):
|
||||||
|
```python
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
|
children=[
|
||||||
|
_filter_field(
|
||||||
|
"Original Currency",
|
||||||
|
Component(
|
||||||
|
tag_name="input",
|
||||||
|
attributes=[
|
||||||
|
("type", "text"),
|
||||||
|
("name", "filter-price_currency"),
|
||||||
|
("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(
|
||||||
|
"Converted Currency",
|
||||||
|
Component(
|
||||||
|
tag_name="input",
|
||||||
|
attributes=[
|
||||||
|
("type", "text"),
|
||||||
|
("name", "filter-converted_currency"),
|
||||||
|
("value", converted_currency_value),
|
||||||
|
("placeholder", "e.g. USD, EUR"),
|
||||||
|
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create New FilterBars in `common/components/filters.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `common/components/filters.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
|
||||||
|
|
||||||
|
Append these three new filter bar components to `common/components/filters.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def DeviceFilterBar(
|
||||||
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
|
) -> SafeText:
|
||||||
|
"""Collapsible filter bar for the Device list."""
|
||||||
|
from games.models import Device
|
||||||
|
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
|
type_options = Device.DEVICE_TYPES
|
||||||
|
type_choice = _filter_get_choice(existing, "type")
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
|
children=[
|
||||||
|
_filter_field(
|
||||||
|
"Device Type",
|
||||||
|
_enum_filter(
|
||||||
|
"type",
|
||||||
|
type_options,
|
||||||
|
type_choice,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
|
def PlatformFilterBar(
|
||||||
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
|
) -> SafeText:
|
||||||
|
"""Collapsible filter bar for the Platform list."""
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
|
|
||||||
|
name_value = existing.get("name", {}).get("value", "")
|
||||||
|
group_value = existing.get("group", {}).get("value", "")
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
|
children=[
|
||||||
|
_filter_field(
|
||||||
|
"Platform Name",
|
||||||
|
Component(
|
||||||
|
tag_name="input",
|
||||||
|
attributes=[
|
||||||
|
("type", "text"),
|
||||||
|
("name", "filter-name"),
|
||||||
|
("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(
|
||||||
|
"Platform Group",
|
||||||
|
Component(
|
||||||
|
tag_name="input",
|
||||||
|
attributes=[
|
||||||
|
("type", "text"),
|
||||||
|
("name", "filter-group"),
|
||||||
|
("value", group_value),
|
||||||
|
("placeholder", "e.g. Nintendo"),
|
||||||
|
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
|
def PlayEventFilterBar(
|
||||||
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
|
) -> SafeText:
|
||||||
|
"""Collapsible filter bar for the PlayEvent list."""
|
||||||
|
from games.models import PlayEvent
|
||||||
|
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
|
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
Component(
|
||||||
|
tag_name="div",
|
||||||
|
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||||
|
children=[
|
||||||
|
_filter_field(
|
||||||
|
"Game",
|
||||||
|
_model_filter(
|
||||||
|
"game",
|
||||||
|
game_choice,
|
||||||
|
search_url="/api/games/search",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
label="Days to Finish",
|
||||||
|
input_name_prefix="filter-days-to-finish",
|
||||||
|
min_value=days_min,
|
||||||
|
max_value=days_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=365,
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 1",
|
||||||
|
max_placeholder="e.g. 30",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
|
||||||
|
|
||||||
|
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Import section:
|
||||||
|
from common.components.filters import (
|
||||||
|
FilterBar,
|
||||||
|
PurchaseFilterBar,
|
||||||
|
SessionFilterBar,
|
||||||
|
DeviceFilterBar,
|
||||||
|
PlatformFilterBar,
|
||||||
|
PlayEventFilterBar,
|
||||||
|
)
|
||||||
|
|
||||||
|
# In __all__:
|
||||||
|
"FilterBar",
|
||||||
|
"PurchaseFilterBar",
|
||||||
|
"SessionFilterBar",
|
||||||
|
"DeviceFilterBar",
|
||||||
|
"PlatformFilterBar",
|
||||||
|
"PlayEventFilterBar",
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/views/device.py`
|
||||||
|
- Modify: `games/views/platform.py`
|
||||||
|
- Modify: `games/views/playevent.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
|
||||||
|
|
||||||
|
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# At top of games/views/device.py:
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from common.components import DeviceFilterBar, ModuleScript
|
||||||
|
from games.filters import parse_device_filter
|
||||||
|
|
||||||
|
# Inside list_devices(request):
|
||||||
|
devices = Device.objects.order_by("-created_at")
|
||||||
|
|
||||||
|
filter_json = request.GET.get("filter", "")
|
||||||
|
if filter_json:
|
||||||
|
device_filter = parse_device_filter(filter_json)
|
||||||
|
if device_filter is not None:
|
||||||
|
devices = devices.filter(device_filter.to_q())
|
||||||
|
|
||||||
|
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||||
|
|
||||||
|
# ... create data dict ...
|
||||||
|
|
||||||
|
# Prepend the filter bar above table:
|
||||||
|
filter_bar = DeviceFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||||
|
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||||
|
)
|
||||||
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
|
return render_page(
|
||||||
|
request,
|
||||||
|
content,
|
||||||
|
title="Manage devices",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
|
||||||
|
|
||||||
|
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# At top of games/views/platform.py:
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from common.components import PlatformFilterBar, ModuleScript
|
||||||
|
from games.filters import parse_platform_filter
|
||||||
|
|
||||||
|
# Inside list_platforms(request):
|
||||||
|
platforms = Platform.objects.order_by("name")
|
||||||
|
|
||||||
|
filter_json = request.GET.get("filter", "")
|
||||||
|
if filter_json:
|
||||||
|
platform_filter = parse_platform_filter(filter_json)
|
||||||
|
if platform_filter is not None:
|
||||||
|
platforms = platforms.filter(platform_filter.to_q())
|
||||||
|
|
||||||
|
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||||
|
|
||||||
|
# ... create data dict ...
|
||||||
|
|
||||||
|
filter_bar = PlatformFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||||
|
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||||
|
)
|
||||||
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
|
return render_page(
|
||||||
|
request,
|
||||||
|
content,
|
||||||
|
title="Manage platforms",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
|
||||||
|
|
||||||
|
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# At top of games/views/playevent.py:
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from common.components import PlayEventFilterBar
|
||||||
|
from games.filters import parse_playevent_filter
|
||||||
|
|
||||||
|
# Inside list_playevents(request):
|
||||||
|
playevents = PlayEvent.objects.order_by("-created_at")
|
||||||
|
|
||||||
|
filter_json = request.GET.get("filter", "")
|
||||||
|
if filter_json:
|
||||||
|
playevent_filter = parse_playevent_filter(filter_json)
|
||||||
|
if playevent_filter is not None:
|
||||||
|
playevents = playevents.filter(playevent_filter.to_q())
|
||||||
|
|
||||||
|
playevents, page_obj, elided_page_range = paginate(request, playevents)
|
||||||
|
|
||||||
|
# ... create data ...
|
||||||
|
|
||||||
|
filter_bar = PlayEventFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||||
|
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||||
|
)
|
||||||
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
|
return render_page(
|
||||||
|
request,
|
||||||
|
content,
|
||||||
|
title="Manage play events",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Support new preset modes in Preset View/Model
|
||||||
|
|
||||||
|
Ensure FilterPreset allows `devices` and `platforms` modes.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `games/models.py`
|
||||||
|
- Modify: `games/views/filter_presets.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Expand FilterPreset mode choices**
|
||||||
|
|
||||||
|
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Inside FilterPreset class:
|
||||||
|
MODE_CHOICES = [
|
||||||
|
("games", "Games"),
|
||||||
|
("sessions", "Sessions"),
|
||||||
|
("purchases", "Purchases"),
|
||||||
|
("playevents", "Play Events"),
|
||||||
|
("devices", "Devices"),
|
||||||
|
("platforms", "Platforms"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add Render Tests for new FilterBars
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_filter_bars.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
|
||||||
|
|
||||||
|
Add test cases in `tests/test_filter_bars.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_device_filter_bar(self):
|
||||||
|
from common.components import DeviceFilterBar
|
||||||
|
html = str(
|
||||||
|
DeviceFilterBar(
|
||||||
|
filter_json="",
|
||||||
|
preset_list_url="/presets/devices/list",
|
||||||
|
preset_save_url="/presets/devices/save",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
|
||||||
|
|
||||||
|
def test_platform_filter_bar(self):
|
||||||
|
from common.components import PlatformFilterBar
|
||||||
|
html = str(
|
||||||
|
PlatformFilterBar(
|
||||||
|
filter_json="",
|
||||||
|
preset_list_url="/presets/platforms/list",
|
||||||
|
preset_save_url="/presets/platforms/save",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
|
||||||
|
|
||||||
|
def test_playevent_filter_bar(self):
|
||||||
|
from common.components import PlayEventFilterBar
|
||||||
|
html = str(
|
||||||
|
PlayEventFilterBar(
|
||||||
|
filter_json="",
|
||||||
|
preset_list_url="/presets/playevents/list",
|
||||||
|
preset_save_url="/presets/playevents/save",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run all test suites to confirm complete success**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_filter_bars.py -v`
|
||||||
|
Expected: ALL filter bar render tests pass.
|
||||||
@@ -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)
|
||||||
|
|||||||
+540
-20
@@ -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,15 +59,46 @@ 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
|
||||||
|
|
||||||
|
session_count: IntCriterion | None = None
|
||||||
|
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
|
||||||
|
|
||||||
|
# Cross-entity filters
|
||||||
|
session_filter: SessionFilter | None = None
|
||||||
|
purchase_filter: PurchaseFilter | None = None
|
||||||
|
playevent_filter: PlayEventFilter | None = None
|
||||||
|
platform_filter: PlatformFilter | None = None
|
||||||
|
|
||||||
def to_q(self) -> Q:
|
def to_q(self) -> Q:
|
||||||
q = Q()
|
q = Q()
|
||||||
|
|
||||||
@@ -94,6 +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.platform_group is not None:
|
||||||
|
q &= self.platform_group.to_q("platform__group")
|
||||||
|
|
||||||
|
if self.session_count is not None:
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if self.session_average is not None:
|
||||||
|
from django.db.models import Avg
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 = (
|
||||||
@@ -105,6 +307,43 @@ class GameFilter(OperatorFilter):
|
|||||||
search_q = ~search_q
|
search_q = ~search_q
|
||||||
q &= search_q
|
q &= search_q
|
||||||
|
|
||||||
|
# Cross-entity filters
|
||||||
|
if self.session_filter is not None:
|
||||||
|
from games.models import Session
|
||||||
|
|
||||||
|
session_q = self.session_filter.to_q()
|
||||||
|
matching_ids = Session.objects.filter(session_q).values_list(
|
||||||
|
"game_id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.purchase_filter is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
purchase_q = self.purchase_filter.to_q()
|
||||||
|
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||||
|
"games__id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.playevent_filter is not None:
|
||||||
|
from games.models import PlayEvent
|
||||||
|
|
||||||
|
playevent_q = self.playevent_filter.to_q()
|
||||||
|
matching_ids = PlayEvent.objects.filter(playevent_q).values_list(
|
||||||
|
"game_id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
if self.platform_filter is not None:
|
||||||
|
from games.models import Platform
|
||||||
|
|
||||||
|
platform_q = self.platform_filter.to_q()
|
||||||
|
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||||
|
"id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
|
||||||
# ── AND / OR / NOT sub-filters ──
|
# ── AND / OR / NOT sub-filters ──
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
if sub is not None:
|
if sub is not None:
|
||||||
@@ -119,6 +358,10 @@ class GameFilter(OperatorFilter):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _playtime_to_q(c: IntCriterion) -> Q:
|
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||||
|
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||||
"""Convert minutes-based criterion to a DurationField Q object.
|
"""Convert minutes-based criterion to a DurationField Q object.
|
||||||
|
|
||||||
Django stores DurationField as microseconds in SQLite, so we convert
|
Django stores DurationField as microseconds in SQLite, so we convert
|
||||||
@@ -129,7 +372,6 @@ class GameFilter(OperatorFilter):
|
|||||||
from common.criteria import Modifier
|
from common.criteria import Modifier
|
||||||
|
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
field = "playtime"
|
|
||||||
td_val = timedelta(minutes=c.value)
|
td_val = timedelta(minutes=c.value)
|
||||||
|
|
||||||
if m == Modifier.EQUALS:
|
if m == Modifier.EQUALS:
|
||||||
@@ -164,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 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -180,7 +431,10 @@ class SessionFilter(OperatorFilter):
|
|||||||
device: MultiCriterion | None = None # filters on device_id
|
device: MultiCriterion | None = None # filters on device_id
|
||||||
emulated: BoolCriterion | None = None
|
emulated: BoolCriterion | None = None
|
||||||
note: StringCriterion | None = None
|
note: StringCriterion | None = None
|
||||||
duration_minutes: IntCriterion | None = None # on duration_total
|
duration_minutes: IntCriterion | None = None # on duration_total (legacy alias)
|
||||||
|
duration_total_minutes: IntCriterion | None = None
|
||||||
|
duration_manual_minutes: IntCriterion | None = None
|
||||||
|
duration_calculated_minutes: IntCriterion | None = None
|
||||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||||
timestamp_start: StringCriterion | None = None # date string
|
timestamp_start: StringCriterion | None = None # date string
|
||||||
timestamp_end: StringCriterion | None = None # date string
|
timestamp_end: StringCriterion | None = None # date string
|
||||||
@@ -193,23 +447,14 @@ class SessionFilter(OperatorFilter):
|
|||||||
# Cross-entity: sessions for games matching these criteria
|
# Cross-entity: sessions for games matching these criteria
|
||||||
game_filter: GameFilter | None = None
|
game_filter: GameFilter | None = None
|
||||||
|
|
||||||
def to_q(self) -> Q:
|
# Cross-entity: sessions for devices matching these criteria
|
||||||
|
device_filter: DeviceFilter | None = None
|
||||||
|
|
||||||
|
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
q = Q()
|
q = Q()
|
||||||
|
|
||||||
if self.game is not None:
|
|
||||||
q &= self.game.to_q("game_id")
|
|
||||||
if self.device is not None:
|
|
||||||
q &= self.device.to_q("device_id")
|
|
||||||
if self.emulated is not None:
|
|
||||||
q &= self.emulated.to_q("emulated")
|
|
||||||
if self.note is not None:
|
|
||||||
q &= self.note.to_q("note")
|
|
||||||
if self.duration_minutes is not None:
|
|
||||||
c = self.duration_minutes
|
|
||||||
td_val = timedelta(minutes=c.value)
|
td_val = timedelta(minutes=c.value)
|
||||||
field = "duration_total"
|
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
if m == Modifier.EQUALS:
|
if m == Modifier.EQUALS:
|
||||||
q &= Q(
|
q &= Q(
|
||||||
@@ -241,6 +486,31 @@ class SessionFilter(OperatorFilter):
|
|||||||
q &= Q(**{f"{field}": timedelta(0)})
|
q &= Q(**{f"{field}": timedelta(0)})
|
||||||
elif m == Modifier.NOT_NULL:
|
elif m == Modifier.NOT_NULL:
|
||||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||||
|
return q
|
||||||
|
|
||||||
|
def to_q(self) -> Q:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
|
||||||
|
if self.game is not None:
|
||||||
|
q &= self.game.to_q("game_id")
|
||||||
|
if self.device is not None:
|
||||||
|
q &= self.device.to_q("device_id")
|
||||||
|
if self.emulated is not None:
|
||||||
|
q &= self.emulated.to_q("emulated")
|
||||||
|
if self.note is not None:
|
||||||
|
q &= self.note.to_q("note")
|
||||||
|
if self.duration_minutes is not None:
|
||||||
|
q &= self._duration_to_q(self.duration_minutes, "duration_total")
|
||||||
|
if self.duration_total_minutes is not None:
|
||||||
|
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
||||||
|
if self.duration_manual_minutes is not None:
|
||||||
|
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||||
|
if self.duration_calculated_minutes is not None:
|
||||||
|
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)
|
||||||
@@ -278,6 +548,14 @@ class SessionFilter(OperatorFilter):
|
|||||||
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)
|
||||||
|
|
||||||
|
# Cross-entity filter: sessions for devices matching DeviceFilter
|
||||||
|
if self.device_filter is not None:
|
||||||
|
from games.models import Device
|
||||||
|
|
||||||
|
device_q = self.device_filter.to_q()
|
||||||
|
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||||
|
q &= Q(device_id__in=matching_ids)
|
||||||
|
|
||||||
# AND / OR / NOT
|
# AND / OR / NOT
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
if sub is not None:
|
if sub is not None:
|
||||||
@@ -305,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
|
||||||
@@ -317,12 +595,19 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
created_at: StringCriterion | None = None
|
created_at: StringCriterion | None = None
|
||||||
updated_at: StringCriterion | None = None
|
updated_at: StringCriterion | None = None
|
||||||
|
|
||||||
|
infinite: BoolCriterion | None = None
|
||||||
|
needs_price_update: BoolCriterion | None = None
|
||||||
|
converted_currency: StringCriterion | None = None
|
||||||
|
|
||||||
# Free-text search
|
# Free-text search
|
||||||
search: StringCriterion | None = None
|
search: StringCriterion | None = None
|
||||||
|
|
||||||
# Cross-entity: purchases for games matching these criteria
|
# Cross-entity: purchases for games matching these criteria
|
||||||
game_filter: GameFilter | None = None
|
game_filter: GameFilter | None = None
|
||||||
|
|
||||||
|
# Cross-entity: purchases for platforms matching these criteria
|
||||||
|
platform_filter: PlatformFilter | None = None
|
||||||
|
|
||||||
def to_q(self) -> Q:
|
def to_q(self) -> Q:
|
||||||
q = Q()
|
q = Q()
|
||||||
|
|
||||||
@@ -354,6 +639,12 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
q &= self.created_at.to_q("created_at")
|
q &= self.created_at.to_q("created_at")
|
||||||
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.infinite is not None:
|
||||||
|
q &= self.infinite.to_q("infinite")
|
||||||
|
if self.needs_price_update is not None:
|
||||||
|
q &= self.needs_price_update.to_q("needs_price_update")
|
||||||
|
if self.converted_currency is not None:
|
||||||
|
q &= self.converted_currency.to_q("converted_currency")
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -374,6 +665,16 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
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(games__id__in=matching_ids)
|
q &= Q(games__id__in=matching_ids)
|
||||||
|
|
||||||
|
# Cross-entity platform filter
|
||||||
|
if self.platform_filter is not None:
|
||||||
|
from games.models import Platform
|
||||||
|
|
||||||
|
platform_q = self.platform_filter.to_q()
|
||||||
|
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||||
|
"id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
if sub is not None:
|
if sub is not None:
|
||||||
if self.AND is not None:
|
if self.AND is not None:
|
||||||
@@ -420,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)
|
||||||
|
|
||||||
@@ -442,6 +743,213 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
return criterion.to_q("games")
|
return criterion.to_q("games")
|
||||||
|
|
||||||
|
|
||||||
|
# ── DeviceFilter ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceFilter(OperatorFilter):
|
||||||
|
"""Filter for the Device model."""
|
||||||
|
|
||||||
|
AND: DeviceFilter | None = None
|
||||||
|
OR: DeviceFilter | None = None
|
||||||
|
NOT: DeviceFilter | None = None
|
||||||
|
|
||||||
|
name: StringCriterion | None = None
|
||||||
|
type: ChoiceCriterion | None = None
|
||||||
|
created_at: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
search: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity: Devices that have sessions matching these criteria
|
||||||
|
session_filter: SessionFilter | None = None
|
||||||
|
|
||||||
|
def to_q(self) -> Q:
|
||||||
|
q = Q()
|
||||||
|
|
||||||
|
if self.name is not None:
|
||||||
|
q &= self.name.to_q("name")
|
||||||
|
if self.type is not None:
|
||||||
|
q &= self.type.to_q("type")
|
||||||
|
if self.created_at is not None:
|
||||||
|
q &= self.created_at.to_q("created_at")
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if self.search is not None and self.search.value:
|
||||||
|
search_q = Q(name__icontains=self.search.value) | Q(
|
||||||
|
type__icontains=self.search.value
|
||||||
|
)
|
||||||
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
|
search_q = ~search_q
|
||||||
|
q &= search_q
|
||||||
|
|
||||||
|
# Cross-entity filter: session_filter
|
||||||
|
if self.session_filter is not None:
|
||||||
|
from games.models import Session
|
||||||
|
|
||||||
|
session_q = self.session_filter.to_q()
|
||||||
|
matching_ids = Session.objects.filter(session_q).values_list(
|
||||||
|
"device_id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
sub = self.sub_filter()
|
||||||
|
if sub is not None:
|
||||||
|
if self.AND is not None:
|
||||||
|
q &= sub.to_q()
|
||||||
|
elif self.OR is not None:
|
||||||
|
q |= sub.to_q()
|
||||||
|
elif self.NOT is not None:
|
||||||
|
q &= ~sub.to_q()
|
||||||
|
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
# ── PlatformFilter ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlatformFilter(OperatorFilter):
|
||||||
|
"""Filter for the Platform model."""
|
||||||
|
|
||||||
|
AND: PlatformFilter | None = None
|
||||||
|
OR: PlatformFilter | None = None
|
||||||
|
NOT: PlatformFilter | None = None
|
||||||
|
|
||||||
|
name: StringCriterion | None = None
|
||||||
|
group: StringCriterion | None = None
|
||||||
|
icon: StringCriterion | None = None
|
||||||
|
created_at: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
search: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity
|
||||||
|
game_filter: GameFilter | None = None
|
||||||
|
purchase_filter: PurchaseFilter | None = None
|
||||||
|
|
||||||
|
def to_q(self) -> Q:
|
||||||
|
q = Q()
|
||||||
|
|
||||||
|
if self.name is not None:
|
||||||
|
q &= self.name.to_q("name")
|
||||||
|
if self.group is not None:
|
||||||
|
q &= self.group.to_q("group")
|
||||||
|
if self.icon is not None:
|
||||||
|
q &= self.icon.to_q("icon")
|
||||||
|
if self.created_at is not None:
|
||||||
|
q &= self.created_at.to_q("created_at")
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if self.search is not None and self.search.value:
|
||||||
|
search_q = Q(name__icontains=self.search.value) | Q(
|
||||||
|
group__icontains=self.search.value
|
||||||
|
)
|
||||||
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
|
search_q = ~search_q
|
||||||
|
q &= search_q
|
||||||
|
|
||||||
|
# Cross-entity filter: game_filter
|
||||||
|
if self.game_filter is not None:
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
game_q = self.game_filter.to_q()
|
||||||
|
matching_ids = Game.objects.filter(game_q).values_list(
|
||||||
|
"platform_id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
# Cross-entity filter: purchase_filter
|
||||||
|
if self.purchase_filter is not None:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
purchase_q = self.purchase_filter.to_q()
|
||||||
|
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||||
|
"platform_id", flat=True
|
||||||
|
)
|
||||||
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
sub = self.sub_filter()
|
||||||
|
if sub is not None:
|
||||||
|
if self.AND is not None:
|
||||||
|
q &= sub.to_q()
|
||||||
|
elif self.OR is not None:
|
||||||
|
q |= sub.to_q()
|
||||||
|
elif self.NOT is not None:
|
||||||
|
q &= ~sub.to_q()
|
||||||
|
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
# ── PlayEventFilter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayEventFilter(OperatorFilter):
|
||||||
|
"""Filter for the PlayEvent model."""
|
||||||
|
|
||||||
|
AND: PlayEventFilter | None = None
|
||||||
|
OR: PlayEventFilter | None = None
|
||||||
|
NOT: PlayEventFilter | None = None
|
||||||
|
|
||||||
|
game: MultiCriterion | None = None # filters on game_id
|
||||||
|
started: StringCriterion | None = None # date string
|
||||||
|
ended: StringCriterion | None = None # date string
|
||||||
|
days_to_finish: IntCriterion | None = None
|
||||||
|
note: StringCriterion | None = None
|
||||||
|
created_at: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
search: StringCriterion | None = None
|
||||||
|
|
||||||
|
# Cross-entity: PlayEvents for games matching these criteria
|
||||||
|
game_filter: GameFilter | None = None
|
||||||
|
|
||||||
|
def to_q(self) -> Q:
|
||||||
|
q = Q()
|
||||||
|
|
||||||
|
if self.game is not None:
|
||||||
|
q &= self.game.to_q("game_id")
|
||||||
|
if self.started is not None:
|
||||||
|
q &= self.started.to_q("started")
|
||||||
|
if self.ended is not None:
|
||||||
|
q &= self.ended.to_q("ended")
|
||||||
|
if self.days_to_finish is not None:
|
||||||
|
q &= self.days_to_finish.to_q("days_to_finish")
|
||||||
|
if self.note is not None:
|
||||||
|
q &= self.note.to_q("note")
|
||||||
|
if self.created_at is not None:
|
||||||
|
q &= self.created_at.to_q("created_at")
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if self.search is not None and self.search.value:
|
||||||
|
search_q = Q(game__name__icontains=self.search.value) | Q(
|
||||||
|
note__icontains=self.search.value
|
||||||
|
)
|
||||||
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
|
search_q = ~search_q
|
||||||
|
q &= search_q
|
||||||
|
|
||||||
|
# Cross-entity filter: game_filter
|
||||||
|
if self.game_filter is not None:
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
game_q = self.game_filter.to_q()
|
||||||
|
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||||
|
q &= Q(game_id__in=matching_ids)
|
||||||
|
|
||||||
|
sub = self.sub_filter()
|
||||||
|
if sub is not None:
|
||||||
|
if self.AND is not None:
|
||||||
|
q &= sub.to_q()
|
||||||
|
elif self.OR is not None:
|
||||||
|
q |= sub.to_q()
|
||||||
|
elif self.NOT is not None:
|
||||||
|
q &= ~sub.to_q()
|
||||||
|
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
# ── Convenience helpers ────────────────────────────────────────────────────
|
# ── Convenience helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -455,3 +963,15 @@ def parse_session_filter(json_str: str) -> SessionFilter | None:
|
|||||||
|
|
||||||
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
|
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
|
||||||
return filter_from_json(PurchaseFilter, json_str)
|
return filter_from_json(PurchaseFilter, json_str)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||||
|
return filter_from_json(DeviceFilter, json_str)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||||
|
return filter_from_json(PlatformFilter, json_str)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||||
|
return filter_from_json(PlayEventFilter, json_str)
|
||||||
|
|||||||
+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 = [
|
||||||
|
|||||||
@@ -501,6 +501,8 @@ class FilterPreset(models.Model):
|
|||||||
("sessions", "Sessions"),
|
("sessions", "Sessions"),
|
||||||
("purchases", "Purchases"),
|
("purchases", "Purchases"),
|
||||||
("playevents", "Play Events"),
|
("playevents", "Play Events"),
|
||||||
|
("devices", "Devices"),
|
||||||
|
("platforms", "Platforms"),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|||||||
+43
-137
@@ -293,85 +293,26 @@
|
|||||||
--leading-5: 20px;
|
--leading-5: 20px;
|
||||||
--radius-base: 12px;
|
--radius-base: 12px;
|
||||||
--color-body: var(--color-gray-600);
|
--color-body: var(--color-gray-600);
|
||||||
--color-body-subtle: var(--color-gray-500);
|
|
||||||
--color-heading: var(--color-gray-900);
|
--color-heading: var(--color-gray-900);
|
||||||
--color-fg-brand-subtle: var(--color-blue-200);
|
|
||||||
--color-fg-brand: var(--color-blue-700);
|
--color-fg-brand: var(--color-blue-700);
|
||||||
--color-fg-brand-strong: var(--color-blue-900);
|
|
||||||
--color-fg-success: var(--color-emerald-700);
|
|
||||||
--color-fg-success-strong: var(--color-emerald-900);
|
|
||||||
--color-fg-danger: var(--color-rose-700);
|
|
||||||
--color-fg-danger-strong: var(--color-rose-900);
|
|
||||||
--color-fg-warning-subtle: var(--color-orange-600);
|
|
||||||
--color-fg-warning: var(--color-orange-900);
|
|
||||||
--color-fg-yellow: var(--color-yellow-400);
|
|
||||||
--color-fg-disabled: var(--color-gray-400);
|
--color-fg-disabled: var(--color-gray-400);
|
||||||
--color-fg-purple: var(--color-purple-600);
|
|
||||||
--color-fg-cyan: var(--color-cyan-600);
|
|
||||||
--color-fg-indigo: var(--color-indigo-600);
|
|
||||||
--color-fg-pink: var(--color-pink-600);
|
|
||||||
--color-fg-lime: var(--color-lime-600);
|
|
||||||
--color-neutral-primary-soft: var(--color-white);
|
--color-neutral-primary-soft: var(--color-white);
|
||||||
--color-neutral-primary: var(--color-white);
|
--color-neutral-primary: var(--color-white);
|
||||||
--color-neutral-primary-medium: var(--color-white);
|
--color-neutral-primary-medium: var(--color-white);
|
||||||
--color-neutral-primary-strong: var(--color-white);
|
|
||||||
--color-neutral-secondary-soft: var(--color-gray-50);
|
--color-neutral-secondary-soft: var(--color-gray-50);
|
||||||
--color-neutral-secondary: var(--color-gray-50);
|
--color-neutral-secondary: var(--color-gray-50);
|
||||||
--color-neutral-secondary-medium: var(--color-gray-50);
|
--color-neutral-secondary-medium: var(--color-gray-50);
|
||||||
--color-neutral-secondary-strong: var(--color-gray-50);
|
--color-neutral-secondary-strong: var(--color-gray-50);
|
||||||
--color-neutral-secondary-strongest: var(--color-gray-50);
|
|
||||||
--color-neutral-tertiary-soft: var(--color-gray-100);
|
|
||||||
--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-neutral-quaternary-medium: var(--color-gray-200);
|
|
||||||
--color-gray: var(--color-gray-300);
|
|
||||||
--color-brand-softer: var(--color-blue-50);
|
|
||||||
--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);
|
||||||
--color-success-soft: var(--color-emerald-50);
|
|
||||||
--color-success: var(--color-emerald-700);
|
|
||||||
--color-success-medium: var(--color-emerald-100);
|
|
||||||
--color-success-strong: var(--color-emerald-800);
|
|
||||||
--color-danger-soft: var(--color-rose-50);
|
|
||||||
--color-danger: var(--color-rose-700);
|
|
||||||
--color-danger-medium: var(--color-rose-100);
|
|
||||||
--color-danger-strong: var(--color-rose-800);
|
|
||||||
--color-warning-soft: var(--color-orange-50);
|
|
||||||
--color-warning: var(--color-orange-500);
|
|
||||||
--color-warning-medium: var(--color-orange-100);
|
|
||||||
--color-warning-strong: var(--color-orange-700);
|
|
||||||
--color-dark-soft: var(--color-gray-800);
|
|
||||||
--color-dark: var(--color-gray-800);
|
--color-dark: var(--color-gray-800);
|
||||||
--color-dark-strong: var(--color-gray-900);
|
|
||||||
--color-disabled: var(--color-gray-100);
|
|
||||||
--color-purple: var(--color-purple-500);
|
|
||||||
--color-sky: var(--color-sky-500);
|
|
||||||
--color-teal: var(--color-teal-600);
|
|
||||||
--color-pink: var(--color-pink-600);
|
|
||||||
--color-cyan: var(--color-cyan-500);
|
|
||||||
--color-fuchsia: var(--color-fuchsia-600);
|
|
||||||
--color-indigo: var(--color-indigo-600);
|
|
||||||
--color-orange: var(--color-orange-400);
|
|
||||||
--color-buffer: var(--color-white);
|
|
||||||
--color-buffer-medium: var(--color-white);
|
|
||||||
--color-buffer-strong: var(--color-white);
|
|
||||||
--color-muted: var(--color-gray-50);
|
|
||||||
--color-light-subtle: var(--color-gray-100);
|
|
||||||
--color-light: var(--color-gray-100);
|
--color-light: var(--color-gray-100);
|
||||||
--color-light-medium: var(--color-gray-100);
|
|
||||||
--color-default-subtle: var(--color-gray-200);
|
|
||||||
--color-default: var(--color-gray-200);
|
--color-default: var(--color-gray-200);
|
||||||
--color-default-medium: var(--color-gray-200);
|
--color-default-medium: var(--color-gray-200);
|
||||||
--color-default-strong: var(--color-gray-200);
|
|
||||||
--color-success-subtle: var(--color-emerald-200);
|
|
||||||
--color-danger-subtle: var(--color-rose-200);
|
|
||||||
--color-warning-subtle: var(--color-orange-200);
|
|
||||||
--color-brand-subtle: var(--color-blue-200);
|
|
||||||
--color-brand-light: var(--color-blue-600);
|
|
||||||
--color-dark-subtle: var(--color-gray-800);
|
|
||||||
--color-dark-backdrop: var(--color-gray-950);
|
--color-dark-backdrop: var(--color-gray-950);
|
||||||
--color-accent: #7c3aed;
|
--color-accent: #7c3aed;
|
||||||
}
|
}
|
||||||
@@ -525,6 +466,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.\@container {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
.pointer-events-auto {
|
.pointer-events-auto {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -881,18 +825,12 @@
|
|||||||
.start-0 {
|
.start-0 {
|
||||||
inset-inline-start: calc(var(--spacing) * 0);
|
inset-inline-start: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.end-1 {
|
|
||||||
inset-inline-end: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.end-1\.5 {
|
.end-1\.5 {
|
||||||
inset-inline-end: calc(var(--spacing) * 1.5);
|
inset-inline-end: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
.top-0 {
|
.top-0 {
|
||||||
top: calc(var(--spacing) * 0);
|
top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.top-1 {
|
|
||||||
top: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.top-1\/2 {
|
.top-1\/2 {
|
||||||
top: calc(1 / 2 * 100%);
|
top: calc(1 / 2 * 100%);
|
||||||
}
|
}
|
||||||
@@ -914,9 +852,6 @@
|
|||||||
.bottom-0 {
|
.bottom-0 {
|
||||||
bottom: calc(var(--spacing) * 0);
|
bottom: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.bottom-1 {
|
|
||||||
bottom: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.bottom-1\.5 {
|
.bottom-1\.5 {
|
||||||
bottom: calc(var(--spacing) * 1.5);
|
bottom: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
@@ -1543,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);
|
||||||
}
|
}
|
||||||
@@ -1626,15 +1564,9 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.w-1 {
|
|
||||||
width: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.w-1\/2 {
|
.w-1\/2 {
|
||||||
width: calc(1 / 2 * 100%);
|
width: calc(1 / 2 * 100%);
|
||||||
}
|
}
|
||||||
.w-2 {
|
|
||||||
width: calc(var(--spacing) * 2);
|
|
||||||
}
|
|
||||||
.w-2\.5 {
|
.w-2\.5 {
|
||||||
width: calc(var(--spacing) * 2.5);
|
width: calc(var(--spacing) * 2.5);
|
||||||
}
|
}
|
||||||
@@ -1752,9 +1684,6 @@
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.border-collapse {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.-translate-x-full {
|
.-translate-x-full {
|
||||||
--tw-translate-x: -100%;
|
--tw-translate-x: -100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1771,10 +1700,6 @@
|
|||||||
--tw-translate-x: 100%;
|
--tw-translate-x: 100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
}
|
}
|
||||||
.-translate-y-1 {
|
|
||||||
--tw-translate-y: calc(var(--spacing) * -1);
|
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
|
||||||
}
|
|
||||||
.-translate-y-1\/2 {
|
.-translate-y-1\/2 {
|
||||||
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1820,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));
|
||||||
}
|
}
|
||||||
@@ -1859,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);
|
||||||
}
|
}
|
||||||
@@ -1871,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;
|
||||||
@@ -2160,18 +2094,12 @@
|
|||||||
.bg-amber-50 {
|
.bg-amber-50 {
|
||||||
background-color: var(--color-amber-50);
|
background-color: var(--color-amber-50);
|
||||||
}
|
}
|
||||||
.bg-amber-500 {
|
|
||||||
background-color: var(--color-amber-500);
|
|
||||||
}
|
|
||||||
.bg-amber-500\/15 {
|
.bg-amber-500\/15 {
|
||||||
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
|
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bg-black {
|
|
||||||
background-color: var(--color-black);
|
|
||||||
}
|
|
||||||
.bg-black\/70 {
|
.bg-black\/70 {
|
||||||
background-color: color-mix(in srgb, #000 70%, transparent);
|
background-color: color-mix(in srgb, #000 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2196,9 +2124,6 @@
|
|||||||
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bg-dark-backdrop {
|
|
||||||
background-color: var(--color-dark-backdrop);
|
|
||||||
}
|
|
||||||
.bg-dark-backdrop\/70 {
|
.bg-dark-backdrop\/70 {
|
||||||
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2217,18 +2142,12 @@
|
|||||||
.bg-gray-500 {
|
.bg-gray-500 {
|
||||||
background-color: var(--color-gray-500);
|
background-color: var(--color-gray-500);
|
||||||
}
|
}
|
||||||
.bg-gray-800 {
|
|
||||||
background-color: var(--color-gray-800);
|
|
||||||
}
|
|
||||||
.bg-gray-800\/20 {
|
.bg-gray-800\/20 {
|
||||||
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
|
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
|
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bg-gray-900 {
|
|
||||||
background-color: var(--color-gray-900);
|
|
||||||
}
|
|
||||||
.bg-gray-900\/50 {
|
.bg-gray-900\/50 {
|
||||||
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
|
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2358,18 +2277,6 @@
|
|||||||
fill: white !important;
|
fill: white !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.apexcharts-gridline {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
.dark & {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.apexcharts-xcrosshairs {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
.dark & {
|
|
||||||
stroke: var(--color-default) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.apexcharts-ycrosshairs {
|
.apexcharts-ycrosshairs {
|
||||||
stroke: var(--color-default) !important;
|
stroke: var(--color-default) !important;
|
||||||
.dark & {
|
.dark & {
|
||||||
@@ -2428,9 +2335,6 @@
|
|||||||
.px-6 {
|
.px-6 {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
.py-0 {
|
|
||||||
padding-block: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
.py-0\.5 {
|
.py-0\.5 {
|
||||||
padding-block: calc(var(--spacing) * 0.5);
|
padding-block: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -2657,9 +2561,6 @@
|
|||||||
.text-balance {
|
.text-balance {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
.text-wrap {
|
|
||||||
text-wrap: wrap;
|
|
||||||
}
|
|
||||||
.whitespace-nowrap {
|
.whitespace-nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -2795,9 +2696,6 @@
|
|||||||
.line-through {
|
.line-through {
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
}
|
}
|
||||||
.no-underline {
|
|
||||||
text-decoration-line: none;
|
|
||||||
}
|
|
||||||
.no-underline\! {
|
.no-underline\! {
|
||||||
text-decoration-line: none !important;
|
text-decoration-line: none !important;
|
||||||
}
|
}
|
||||||
@@ -2816,6 +2714,9 @@
|
|||||||
.opacity-0 {
|
.opacity-0 {
|
||||||
opacity: 0%;
|
opacity: 0%;
|
||||||
}
|
}
|
||||||
|
.opacity-50 {
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
.opacity-100 {
|
.opacity-100 {
|
||||||
opacity: 100%;
|
opacity: 100%;
|
||||||
}
|
}
|
||||||
@@ -2864,15 +2765,16 @@
|
|||||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
}
|
}
|
||||||
.backdrop-filter {
|
|
||||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
|
||||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
|
||||||
}
|
|
||||||
.transition {
|
.transition {
|
||||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||||
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));
|
||||||
@@ -3457,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);
|
||||||
@@ -3622,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;
|
||||||
@@ -4524,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")])
|
||||||
],
|
],
|
||||||
|
|||||||
+27
-4
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
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.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
@@ -10,19 +11,28 @@ from common.components import (
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Icon,
|
Icon,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
DeviceFilterBar,
|
||||||
|
ModuleScript,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
from games.filters import parse_device_filter
|
||||||
from games.forms import DeviceForm
|
from games.forms import DeviceForm
|
||||||
from games.models import Device
|
from games.models import Device
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_devices(request: HttpRequest) -> HttpResponse:
|
def list_devices(request: HttpRequest) -> HttpResponse:
|
||||||
devices, page_obj, elided_page_range = paginate(
|
devices = Device.objects.order_by("-created_at")
|
||||||
request, Device.objects.order_by("-created_at")
|
|
||||||
)
|
filter_json = request.GET.get("filter", "")
|
||||||
|
if filter_json:
|
||||||
|
device_filter = parse_device_filter(filter_json)
|
||||||
|
if device_filter is not None:
|
||||||
|
devices = devices.filter(device_filter.to_q())
|
||||||
|
|
||||||
|
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||||
@@ -61,7 +71,20 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return render_page(request, content, title="Manage devices")
|
filter_bar = DeviceFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||||
|
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||||
|
)
|
||||||
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
|
return render_page(
|
||||||
|
request,
|
||||||
|
content,
|
||||||
|
title="Manage devices",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
+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)],
|
||||||
),
|
),
|
||||||
|
|||||||
+27
-4
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
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.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
@@ -10,10 +11,13 @@ from common.components import (
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Icon,
|
Icon,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
PlatformFilterBar,
|
||||||
|
ModuleScript,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
from games.filters import parse_platform_filter
|
||||||
from games.forms import PlatformForm
|
from games.forms import PlatformForm
|
||||||
from games.models import Platform
|
from games.models import Platform
|
||||||
from games.views.general import use_custom_redirect
|
from games.views.general import use_custom_redirect
|
||||||
@@ -21,9 +25,15 @@ from games.views.general import use_custom_redirect
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_platforms(request: HttpRequest) -> HttpResponse:
|
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||||
platforms, page_obj, elided_page_range = paginate(
|
platforms = Platform.objects.order_by("name")
|
||||||
request, Platform.objects.order_by("name")
|
|
||||||
)
|
filter_json = request.GET.get("filter", "")
|
||||||
|
if filter_json:
|
||||||
|
platform_filter = parse_platform_filter(filter_json)
|
||||||
|
if platform_filter is not None:
|
||||||
|
platforms = platforms.filter(platform_filter.to_q())
|
||||||
|
|
||||||
|
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A(
|
"header_action": A(
|
||||||
@@ -68,7 +78,20 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return render_page(request, content, title="Manage platforms")
|
filter_bar = PlatformFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||||
|
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||||
|
)
|
||||||
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
|
return render_page(
|
||||||
|
request,
|
||||||
|
content,
|
||||||
|
title="Manage platforms",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
@@ -17,10 +19,12 @@ from common.components import (
|
|||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
PlayEventFilterBar,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, format_duration, local_strftime
|
from common.time import dateformat, format_duration, local_strftime
|
||||||
from common.utils import paginate
|
from common.utils import paginate
|
||||||
|
from games.filters import parse_playevent_filter
|
||||||
from games.forms import PlayEventForm
|
from games.forms import PlayEventForm
|
||||||
from games.models import Game, PlayEvent, Session
|
from games.models import Game, PlayEvent, Session
|
||||||
|
|
||||||
@@ -126,9 +130,15 @@ def _get_formatted_playtime_for_game_sessions_in_range(
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||||
playevents, page_obj, elided_page_range = paginate(
|
playevents = PlayEvent.objects.order_by("-created_at")
|
||||||
request, PlayEvent.objects.order_by("-created_at")
|
|
||||||
)
|
filter_json = request.GET.get("filter", "")
|
||||||
|
if filter_json:
|
||||||
|
playevent_filter = parse_playevent_filter(filter_json)
|
||||||
|
if playevent_filter is not None:
|
||||||
|
playevents = playevents.filter(playevent_filter.to_q())
|
||||||
|
|
||||||
|
playevents, page_obj, elided_page_range = paginate(request, playevents)
|
||||||
data = create_playevent_tabledata(playevents, request=request)
|
data = create_playevent_tabledata(playevents, request=request)
|
||||||
content = paginated_table_content(
|
content = paginated_table_content(
|
||||||
data,
|
data,
|
||||||
@@ -136,7 +146,20 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return render_page(request, content, title="Manage play events")
|
filter_bar = PlayEventFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||||
|
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||||
|
)
|
||||||
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
|
return render_page(
|
||||||
|
request,
|
||||||
|
content,
|
||||||
|
title="Manage play events",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
+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()
|
||||||
|
|||||||
@@ -186,3 +186,180 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self.assertNotIn("data-match=", html)
|
self.assertNotIn("data-match=", html)
|
||||||
self.assertIn("Finished", html)
|
self.assertIn("Finished", html)
|
||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
|
|
||||||
|
def test_device_filter_bar(self):
|
||||||
|
from common.components import DeviceFilterBar
|
||||||
|
|
||||||
|
html = str(
|
||||||
|
DeviceFilterBar(
|
||||||
|
filter_json="",
|
||||||
|
preset_list_url="/presets/devices/list",
|
||||||
|
preset_save_url="/presets/devices/save",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
|
||||||
|
|
||||||
|
def test_platform_filter_bar(self):
|
||||||
|
from common.components import PlatformFilterBar
|
||||||
|
|
||||||
|
html = str(
|
||||||
|
PlatformFilterBar(
|
||||||
|
filter_json="",
|
||||||
|
preset_list_url="/presets/platforms/list",
|
||||||
|
preset_save_url="/presets/platforms/save",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
|
||||||
|
|
||||||
|
def test_playevent_filter_bar(self):
|
||||||
|
from common.components import PlayEventFilterBar
|
||||||
|
|
||||||
|
html = str(
|
||||||
|
PlayEventFilterBar(
|
||||||
|
filter_json="",
|
||||||
|
preset_list_url="/presets/playevents/list",
|
||||||
|
preset_save_url="/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"))
|
||||||
|
|
||||||
|
|||||||
+579
-1
@@ -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(
|
||||||
@@ -657,3 +683,555 @@ class TestPurchaseNumPurchasesAgainstDB:
|
|||||||
)
|
)
|
||||||
result = set(Purchase.objects.filter(pf.to_q()))
|
result = set(Purchase.objects.filter(pf.to_q()))
|
||||||
assert result == {seeded["single"]}
|
assert result == {seeded["single"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestExpandedFiltersAgainstDB:
|
||||||
|
def _setup_entities(self):
|
||||||
|
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
|
||||||
|
import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# 1. Platform & Game
|
||||||
|
plat, _ = Platform.objects.get_or_create(
|
||||||
|
name="Retro Console", group="Nintendo", icon="retro"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||||
|
|
||||||
|
# Session 1: total 40 minutes (30 calc, 10 manual)
|
||||||
|
s1 = Session.objects.create(
|
||||||
|
game=game,
|
||||||
|
device=dev,
|
||||||
|
timestamp_start=datetime.datetime(
|
||||||
|
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
timestamp_end=datetime.datetime(
|
||||||
|
2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
duration_manual=timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Purchase
|
||||||
|
pur = Purchase.objects.create(
|
||||||
|
platform=plat,
|
||||||
|
date_purchased=datetime.date(2026, 1, 1),
|
||||||
|
infinite=True,
|
||||||
|
price=49.99,
|
||||||
|
price_currency="JPY",
|
||||||
|
converted_price=45.00,
|
||||||
|
converted_currency="USD",
|
||||||
|
needs_price_update=False,
|
||||||
|
)
|
||||||
|
pur.games.add(game)
|
||||||
|
|
||||||
|
# 4. PlayEvent
|
||||||
|
pe = PlayEvent.objects.create(
|
||||||
|
game=game,
|
||||||
|
started=datetime.date(2026, 6, 1),
|
||||||
|
ended=datetime.date(2026, 6, 2),
|
||||||
|
note="Completed 100%",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plat": plat,
|
||||||
|
"game": game,
|
||||||
|
"game2": game2,
|
||||||
|
"dev": dev,
|
||||||
|
"s1": s1,
|
||||||
|
"pur": pur,
|
||||||
|
"pe": pe,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_device_filter_and_cross_entity(self):
|
||||||
|
from games.filters import DeviceFilter
|
||||||
|
from games.models import Device
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# Find devices that have sessions on "Super Mario World"
|
||||||
|
df = DeviceFilter.from_json(
|
||||||
|
{
|
||||||
|
"session_filter": {
|
||||||
|
"game_filter": {
|
||||||
|
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = list(Device.objects.filter(df.to_q()))
|
||||||
|
assert data["dev"] in results
|
||||||
|
|
||||||
|
def test_platform_filter_and_cross_entity(self):
|
||||||
|
from games.filters import PlatformFilter
|
||||||
|
from games.models import Platform
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
# Find platforms with games that are finished
|
||||||
|
pf = PlatformFilter.from_json(
|
||||||
|
{"game_filter": {"status": {"value": ["f"], "modifier": "INCLUDES"}}}
|
||||||
|
)
|
||||||
|
results = list(Platform.objects.filter(pf.to_q()))
|
||||||
|
assert data["plat"] in results
|
||||||
|
|
||||||
|
def test_session_filter_duration_splits(self):
|
||||||
|
from games.filters import SessionFilter
|
||||||
|
from games.models import Session
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
|
||||||
|
# Test duration_total_minutes equals 40
|
||||||
|
sf_tot = SessionFilter.from_json(
|
||||||
|
{"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||||
|
|
||||||
|
# Test duration_manual_minutes equals 10
|
||||||
|
sf_man = SessionFilter.from_json(
|
||||||
|
{"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||||
|
|
||||||
|
# Test duration_calculated_minutes equals 30
|
||||||
|
sf_calc = SessionFilter.from_json(
|
||||||
|
{"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||||
|
|
||||||
|
def test_purchase_filter_new_fields(self):
|
||||||
|
from games.filters import PurchaseFilter
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
|
||||||
|
pf = PurchaseFilter.from_json(
|
||||||
|
{
|
||||||
|
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||||
|
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||||
|
"converted_currency": {"value": "USD", "modifier": "EQUALS"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||||
|
|
||||||
|
def test_game_filter_stats_and_existence(self):
|
||||||
|
from games.filters import GameFilter
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
data = self._setup_entities()
|
||||||
|
|
||||||
|
# purchase_count == 1 (replaces removed has_purchases boolean)
|
||||||
|
gf_pur = GameFilter.from_json(
|
||||||
|
{"purchase_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
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()))
|
||||||
|
|
||||||
|
# session_count = 1
|
||||||
|
gf_cnt = GameFilter.from_json(
|
||||||
|
{"session_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
|
)
|
||||||
|
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