Compare commits

...

26 Commits

Author SHA1 Message Date
lukas 9bf7215125 Fix RangeSlider visual bugs
Django CI/CD / test (push) Failing after 1m14s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-10 20:33:56 +02:00
lukas 5f5ff19390 add make server 2026-06-10 20:33:34 +02:00
lukas 30d35a2368 fix: ensure deselecting presence modifier re-enables string input 2026-06-10 19:14:33 +02:00
lukas 64392c3935 feat: migrate playevent_note to StringCriterion and add note string filter to SessionFilterBar 2026-06-10 18:19:45 +02:00
lukas a1304e19ad test: implement E2E Playwright tests for string multi-mode filters 2026-06-10 17:52:35 +02:00
lukas ab94617f06 feat: integrate StringFilter into PlatformFilterBar and PurchaseFilterBar 2026-06-10 17:52:20 +02:00
lukas 5d6646d8ac feat: add client-side toggle logic and multi-mode serialization for string filters 2026-06-10 17:51:36 +02:00
lukas 919d6c98ee feat: implement StringFilter composite component with 4x2 grid radios 2026-06-10 17:51:07 +02:00
lukas d17e11f2bc test: add comprehensive unit tests for all 8 string criterion modifiers 2026-06-10 17:50:37 +02:00
lukas 17c5fdb8a8 docs: add implementation plan for unifying form checkboxes 2026-06-09 21:01:54 +02:00
lukas 74dffaeae4 feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin 2026-06-09 21:01:54 +02:00
lukas 7fc29fccb8 refactor: allow Checkbox and Radio primitives to render headlessly without labels 2026-06-09 20:42:57 +02:00
lukas 00758d6a50 docs: add design spec and implementation plan for boolean filters improvement 2026-06-09 20:06:41 +02:00
lukas 508b04af19 test: add explicit radio group and True/False choice checks for boolean fields 2026-06-09 20:06:18 +02:00
lukas 6d21ffc4c7 feat: add click-to-deselect behavior and update checked-radio serialization in JS 2026-06-09 20:05:04 +02:00
lukas 9490e55f89 feat: replace single boolean checkboxes with radio groups in all FilterBars 2026-06-09 20:01:02 +02:00
lukas 0b9dd702e1 feat: implement _parse_bool_nullable and _filter_boolean_radio helper 2026-06-09 19:58:20 +02:00
lukas af62120c8d refactor: generalize Checkbox and add Radio primitive component 2026-06-09 19:55:01 +02:00
lukas dd2ebe5888 Implement date filters in purchase list 2026-06-09 19:36:18 +02:00
lukas 835caf6a71 Improve the layout of the purchase filter bar 2026-06-09 19:15:19 +02:00
lukas 231fa483e7 Improve the layout of the game filter bar 2026-06-09 19:15:19 +02:00
lukas 32eb882a98 Use adhoc Component() less 2026-06-09 19:15:19 +02:00
lukas 0179363684 Add more filters 2026-06-09 17:19:09 +02:00
lukas ad5c8d3bb1 Fix filter bars 2026-06-09 14:41:49 +02:00
lukas 89c9ff6367 feat: implement frontend filter bars and integration across all list views
Django CI/CD / test (push) Failing after 58s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-09 13:56:02 +02:00
lukas 5887febbb7 feat: implement comprehensive filters and cross-entity queries
Django CI/CD / test (push) Failing after 1m28s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-09 13:14:05 +02:00
38 changed files with 5649 additions and 586 deletions
+3
View File
@@ -22,6 +22,9 @@ init:
pnpm install
$(MAKE) loadplatforms
server:
uv run python -Wa manage.py runserver
dev:
@pnpm concurrently \
--names "Django,Tailwind" \
+52 -37
View File
@@ -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.
"""
from common.utils import truncate
from common.components.core import (
Component,
HTMLAttribute,
@@ -13,41 +11,6 @@ from common.components.core import (
_render_element,
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 (
GameLink,
GameStatus,
@@ -60,10 +23,56 @@ from common.components.domain import (
_resolve_name_with_icon,
)
from common.components.filters import (
DeviceFilterBar,
FilterBar,
PlatformFilterBar,
PlayEventFilterBar,
PurchaseFilterBar,
SessionFilterBar,
StringFilter,
)
from common.components.primitives import (
H1,
A,
AddForm,
Button,
ButtonGroup,
Checkbox,
CsrfInput,
Div,
ExternalScript,
Icon,
Input,
Label,
Li,
Modal,
ModuleScript,
Pill,
Popover,
PopoverTruncated,
Radio,
SearchField,
SimpleTable,
Span,
TableHeader,
TableRow,
TableTd,
Td,
Template,
Tr,
Ul,
YearPicker,
paginated_table_content,
)
from common.components.search_select import (
DEFAULT_PREFETCH,
FilterSelect,
LabeledOption,
SearchSelect,
SearchSelectOption,
searchselect_selected,
)
from common.utils import truncate
__all__ = [
"truncate",
@@ -76,6 +85,7 @@ __all__ = [
"AddForm",
"Button",
"ButtonGroup",
"Checkbox",
"CsrfInput",
"Div",
"ExternalScript",
@@ -87,6 +97,7 @@ __all__ = [
"Pill",
"Popover",
"PopoverTruncated",
"Radio",
"SearchField",
"DEFAULT_PREFETCH",
"FilterSelect",
@@ -115,4 +126,8 @@ __all__ = [
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
"StringFilter",
]
+3 -4
View File
@@ -6,7 +6,7 @@ from django.template.defaultfilters import floatformat
from django.urls import reverse
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 (
A,
Div,
@@ -33,10 +33,9 @@ def GameLink(
return Span(
attributes=[("class", "truncate-container")],
children=[
Component(
tag_name="a",
A(
href=link,
attributes=[
("href", link),
("class", "underline decoration-slate-500 sm:decoration-2"),
],
children=display if isinstance(display, list) else [display],
File diff suppressed because it is too large Load Diff
+140 -23
View File
@@ -6,10 +6,9 @@ from django.urls import reverse
from django.utils.html import conditional_escape
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.utils import truncate
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
_COLOR_CLASSES = {
"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"
)
div = Component(
tag_name="div",
div = Div(
attributes=[
("data-popover", ""),
("id", id),
@@ -66,12 +64,11 @@ def _popover_html(
("class", popover_tooltip_class),
],
children=[
Component(
tag_name="div",
Div(
attributes=[("class", "px-3 py-2")],
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
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->"
@@ -323,8 +320,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
)
)
return Component(
tag_name="div",
return Div(
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
children=children,
)
@@ -339,6 +335,42 @@ def Div(
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(
type: str = "text",
attributes: list[HTMLAttribute] | None = None,
@@ -369,6 +401,70 @@ def Label(
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(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
@@ -600,8 +696,7 @@ def SearchField(
],
children=["Search"],
),
Component(
tag_name="div",
Div(
attributes=[("class", "relative")],
children=[
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"/>'
"</svg></div>"
),
Component(
tag_name="input",
Input(
type="search",
attributes=[
("type", "search"),
("id", id),
("name", id),
("value", search_string),
@@ -687,8 +781,7 @@ def Modal(
) -> SafeText:
"""Modal overlay with container. Content (form, buttons) goes in children."""
children = children or []
outer = Component(
tag_name="div",
outer = Div(
attributes=[
("id", modal_id),
(
@@ -698,8 +791,7 @@ def Modal(
),
],
children=[
Component(
tag_name="div",
Div(
attributes=[
(
"class",
@@ -714,13 +806,39 @@ def Modal(
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(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Styled table cell."""
children = children or []
return Component(
tag_name="td",
return Td(
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
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):
if i == 0:
cell_elements.append(
Component(
tag_name="th",
Th(
attributes=[
("scope", "row"),
(
@@ -781,7 +898,7 @@ def TableRow(data: dict | list | None = None) -> SafeText:
else:
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(
+9 -1
View File
@@ -431,6 +431,7 @@ def FilterSelect(
items_scroll: int = 10,
placeholder: str = "Search…",
id: str = "",
free_text: bool = False,
) -> SafeText:
"""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
labels even when the value rows come from ``search_url``. ``options``
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 [])]
included = [_normalize_option(option) for option in (included or [])]
@@ -515,7 +521,7 @@ def FilterSelect(
children=[_filter_modifier_pill("", "")],
)
)
if search_url:
if search_url or free_text:
templates.append(
Template(
attributes=[("data-search-select-template", "row")],
@@ -536,6 +542,8 @@ def FilterSelect(
("data-sync-url", "false"),
("class", _CONTAINER_CLASS),
]
if free_text:
container_attributes.append(("data-search-select-free-text", "true"))
if modifier:
container_attributes.append(("data-modifier", modifier))
if id:
-3
View File
@@ -209,9 +209,6 @@ textarea:disabled {
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;
}
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 {
@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.
+111
View File
@@ -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
+164
View File
@@ -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",
}
+115
View File
@@ -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
+145
View File
@@ -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()
+15
View File
@@ -59,6 +59,12 @@ class GameOption(Schema): # mirrors SearchSelectOption
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])
def search_games(request, q: str = "", limit: int = 10):
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]]
@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("/games", game_router)
api.add_router("/devices", device_router)
+561 -41
View File
@@ -18,6 +18,7 @@ from django.db.models import Q
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
DateCriterion,
FloatCriterion,
IntCriterion,
Modifier,
@@ -58,15 +59,46 @@ class GameFilter(OperatorFilter):
original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget
platform_group: MultiCriterion | None = None # platform__group__in
status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_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)
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:
q = Q()
@@ -94,6 +126,176 @@ class GameFilter(OperatorFilter):
if self.updated_at is not None:
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) ──
if self.search is not None and self.search.value:
search_q = (
@@ -105,6 +307,43 @@ class GameFilter(OperatorFilter):
search_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 ──
sub = self.sub_filter()
if sub is not None:
@@ -119,6 +358,10 @@ class GameFilter(OperatorFilter):
@staticmethod
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.
Django stores DurationField as microseconds in SQLite, so we convert
@@ -129,7 +372,6 @@ class GameFilter(OperatorFilter):
from common.criteria import Modifier
m = c.modifier
field = "playtime"
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
@@ -164,6 +406,15 @@ class GameFilter(OperatorFilter):
return ~Q(**{f"{field}": timedelta(0)})
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 ──────────────────────────────────────────────────────────
@@ -180,7 +431,10 @@ class SessionFilter(OperatorFilter):
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | 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
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
@@ -193,6 +447,47 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
# 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
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"{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
def to_q(self) -> Q:
from datetime import timedelta
@@ -207,40 +502,15 @@ class SessionFilter(OperatorFilter):
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)
field = "duration_total"
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"{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)})
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.value:
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)
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
sub = self.sub_filter()
if sub is not None:
@@ -305,8 +583,8 @@ class PurchaseFilter(OperatorFilter):
name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
date_purchased: DateCriterion | None = None
date_refunded: DateCriterion | None = None
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None
@@ -317,12 +595,19 @@ class PurchaseFilter(OperatorFilter):
created_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
search: StringCriterion | None = None
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
# Cross-entity: purchases for platforms matching these criteria
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q:
q = Q()
@@ -354,6 +639,12 @@ class PurchaseFilter(OperatorFilter):
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
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
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)
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()
if sub is not None:
if self.AND is not None:
@@ -420,9 +721,9 @@ class PurchaseFilter(OperatorFilter):
subquery = subquery.filter(games=game_id)
if criterion.modifier == Modifier.INCLUDES_ONLY:
extra_ids = Game.objects.exclude(
id__in=criterion.value
).values_list("id", flat=True)
extra_ids = Game.objects.exclude(id__in=criterion.value).values_list(
"id", flat=True
)
if extra_ids:
subquery = subquery.exclude(games__in=extra_ids)
@@ -442,6 +743,213 @@ class PurchaseFilter(OperatorFilter):
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 ────────────────────────────────────────────────────
@@ -455,3 +963,15 @@ def parse_session_filter(json_str: str) -> SessionFilter | None:
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
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
View File
@@ -8,6 +8,7 @@ from common.components import (
SearchSelectOption,
searchselect_selected,
)
from common.components.primitives import Checkbox
from games.models import (
Device,
Game,
@@ -25,6 +26,33 @@ custom_datetime_widget = forms.DateTimeInput(
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):
def label_from_instance(self, obj) -> str:
return obj.search_label
@@ -128,7 +156,7 @@ class SearchSelectMultiple(SearchSelectWidget):
return data.get(name)
class SessionForm(forms.ModelForm):
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(
@@ -212,7 +240,7 @@ class RelatedPurchaseChoiceField(forms.ModelChoiceField):
return name or obj.standardized_name
class PurchaseForm(forms.ModelForm):
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["platform"].queryset = Platform.objects.order_by("name")
@@ -305,7 +333,7 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name
class GameForm(forms.ModelForm):
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"),
required=False,
@@ -329,7 +357,7 @@ class GameForm(forms.ModelForm):
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm):
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
class Meta:
model = Platform
fields = [
@@ -340,14 +368,14 @@ class PlatformForm(forms.ModelForm):
widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm):
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(
@@ -382,7 +410,7 @@ class PlayEventForm(forms.ModelForm):
return session
class GameStatusChangeForm(forms.ModelForm):
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
+2
View File
@@ -501,6 +501,8 @@ class FilterPreset(models.Model):
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
name = models.CharField(max_length=255)
+43 -137
View File
@@ -293,85 +293,26 @@
--leading-5: 20px;
--radius-base: 12px;
--color-body: var(--color-gray-600);
--color-body-subtle: var(--color-gray-500);
--color-heading: var(--color-gray-900);
--color-fg-brand-subtle: var(--color-blue-200);
--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-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: 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: var(--color-gray-50);
--color-neutral-secondary-medium: 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-medium: var(--color-gray-100);
--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-medium: var(--color-blue-200);
--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-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-medium: var(--color-gray-100);
--color-default-subtle: var(--color-gray-200);
--color-default: 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-accent: #7c3aed;
}
@@ -525,6 +466,9 @@
}
}
@layer utilities {
.\@container {
container-type: inline-size;
}
.pointer-events-auto {
pointer-events: auto;
}
@@ -881,18 +825,12 @@
.start-0 {
inset-inline-start: calc(var(--spacing) * 0);
}
.end-1 {
inset-inline-end: calc(var(--spacing) * 1);
}
.end-1\.5 {
inset-inline-end: calc(var(--spacing) * 1.5);
}
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
@@ -914,9 +852,6 @@
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.bottom-1 {
bottom: calc(var(--spacing) * 1);
}
.bottom-1\.5 {
bottom: calc(var(--spacing) * 1.5);
}
@@ -1543,6 +1478,9 @@
.h-8 {
height: calc(var(--spacing) * 8);
}
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-10 {
height: calc(var(--spacing) * 10);
}
@@ -1626,15 +1564,9 @@
text-align: center;
}
}
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/2 {
width: calc(1 / 2 * 100%);
}
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-2\.5 {
width: calc(var(--spacing) * 2.5);
}
@@ -1752,9 +1684,6 @@
.shrink-0 {
flex-shrink: 0;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-x-full {
--tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1771,10 +1700,6 @@
--tw-translate-x: 100%;
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 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1820,6 +1745,9 @@
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1859,6 +1787,9 @@
.gap-1 {
gap: calc(var(--spacing) * 1);
}
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
@@ -1871,6 +1802,9 @@
.gap-5 {
gap: calc(var(--spacing) * 5);
}
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-6 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@@ -2160,18 +2094,12 @@
.bg-amber-50 {
background-color: var(--color-amber-50);
}
.bg-amber-500 {
background-color: var(--color-amber-500);
}
.bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
}
.bg-black {
background-color: var(--color-black);
}
.bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2196,9 +2124,6 @@
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
.bg-dark-backdrop {
background-color: var(--color-dark-backdrop);
}
.bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2217,18 +2142,12 @@
.bg-gray-500 {
background-color: var(--color-gray-500);
}
.bg-gray-800 {
background-color: var(--color-gray-800);
}
.bg-gray-800\/20 {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
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 {
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2358,18 +2277,6 @@
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 {
stroke: var(--color-default) !important;
.dark & {
@@ -2428,9 +2335,6 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
@@ -2657,9 +2561,6 @@
.text-balance {
text-wrap: balance;
}
.text-wrap {
text-wrap: wrap;
}
.whitespace-nowrap {
white-space: nowrap;
}
@@ -2795,9 +2696,6 @@
.line-through {
text-decoration-line: line-through;
}
.no-underline {
text-decoration-line: none;
}
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2816,6 +2714,9 @@
.opacity-0 {
opacity: 0%;
}
.opacity-50 {
opacity: 50%;
}
.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,);
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-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-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-property: opacity;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -3457,6 +3359,11 @@
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 {
@media (width >= 40rem) {
border-top-left-radius: var(--radius-lg);
@@ -3622,6 +3529,21 @@
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 {
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
rotate: 180deg;
@@ -4524,22 +4446,6 @@ form input:disabled, select:disabled, textarea:disabled {
--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 {
width: 100%;
border-radius: var(--radius-base);
+186 -61
View File
@@ -30,6 +30,24 @@
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. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
@@ -47,11 +65,6 @@
*/
function buildFilterJSON(form) {
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 ──
var searchInput = form.querySelector('[name="filter-search"]');
@@ -87,62 +100,100 @@
}
});
// ── Session-specific fields ──
var pageIsSessions =
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
if (emulated && emulated.checked) {
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
// 1. Text Fields
var textFields = [
{ 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";
var isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
if (isPresence) {
filter[tf.key] = { modifier: modifier };
} else {
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
if (playMin !== "" && playMax !== "") {
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
} else if (playMin !== "") {
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
} else if (playMax !== "") {
filter[durKey] = criterion(pMax, null, "LESS_THAN");
var el = form.querySelector('[name="' + tf.name + '"]');
if (el && el.value.trim()) {
filter[tf.key] = { value: el.value.trim(), modifier: modifier };
}
}
}
});
// ── Purchase-specific: num_purchases ──
var numGamesMin = numberValue(form, "filter-num-purchases-min");
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");
}
// 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");
}
});
if (mastered && mastered.checked) {
filter.mastered = criterion(true, 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);
}
if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) {
return; // both 0 means slider at default
}
var c = buildRangeCriterion(vMin, vMax);
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;
}
@@ -196,10 +247,19 @@
if (!url) return;
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
var path = window.location.pathname;
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) {
if (!r.ok) throw new Error("Failed to load presets");
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. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
@@ -277,8 +358,12 @@
var body = new URLSearchParams();
body.append("name", name);
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
var path = window.location.pathname;
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("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 () {
injectSearchInputs();
setupDeselectableRadios();
setupStringFilters();
loadPresets();
});
})();
+60 -20
View File
@@ -46,8 +46,10 @@
return Math.max(lo, Math.min(hi, value));
}
function getTargetValue(target) {
return parseInt(target ? target.value : 0, 10) || dataMin;
function getTargetValue(target, defaultVal) {
if (!target || target.value === "") return defaultVal;
var parsed = parseInt(target.value, 10);
return isNaN(parsed) ? defaultVal : parsed;
}
function setTargetValue(target, value) {
if (target) target.value = value;
@@ -57,22 +59,30 @@
function updateTrackFill() {
if (!trackFill) return;
var minValue = getTargetValue(minTarget);
var maxValue = getTargetValue(maxTarget);
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
if (mode === "point") {
trackFill.style.left = "0%";
trackFill.style.width = valueToPercent(maxValue) + "%";
trackFill.style.width = valueToPercent(maxVal) + "%";
} else {
var leftPct = valueToPercent(minValue);
var widthPct = valueToPercent(maxValue) - leftPct;
var leftPct = valueToPercent(minVal);
var rightPct = valueToPercent(maxVal);
if (leftPct > rightPct) {
var tmp = leftPct;
leftPct = rightPct;
rightPct = tmp;
}
var widthPct = rightPct - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
}
}
function updateHandles() {
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
minHandle.style.left = valueToPercent(minVal) + "%";
maxHandle.style.left = valueToPercent(maxVal) + "%";
updateTrackFill();
}
@@ -101,7 +111,7 @@
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget))
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
);
if (minTarget)
minTarget.dispatchEvent(
@@ -110,7 +120,7 @@
} else {
setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget), dataMax)
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
);
if (maxTarget)
maxTarget.dispatchEvent(
@@ -135,19 +145,49 @@
// ── Sync from number inputs back to handles ──
function syncFromInputs() {
function syncFromInputs(e) {
if (mode === "point") {
var value =
getTargetValue(minTarget) || getTargetValue(maxTarget);
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
var src = (e && e.target) || minTarget || maxTarget;
var val = src ? src.value : "";
setTargetValue(minTarget, val);
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();
}
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);
if (maxTarget)
minTarget.addEventListener("change", enforceStrictBounds);
}
if (maxTarget) {
maxTarget.addEventListener("input", syncFromInputs);
maxTarget.addEventListener("change", enforceStrictBounds);
}
// ── Mode toggle ──
@@ -172,7 +212,7 @@
var dashSpan = block && block.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, getTargetValue(maxTarget));
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else {
@@ -193,4 +233,4 @@
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
window.initRangeSliders = initAll;
})();
})();
+25 -1
View File
@@ -49,6 +49,7 @@
const name = container.getAttribute("data-name");
const searchUrl = container.getAttribute("data-search-url");
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 alwaysVisible = container.getAttribute("data-always-visible") === "true";
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
// instantly (zero latency) and debounce a server request for the rest;
// no-results stays hidden until the response decides it, to avoid a flash
@@ -258,6 +275,11 @@
// so the client-side filter is authoritative.
const runSearch = () => {
const query = search.value.trim();
if (freeText) {
rebuildFreeTextRow(query);
showPanel();
return;
}
if (searchUrl) {
filterRows(query);
setNoResults(false);
@@ -282,7 +304,9 @@
search.value = "";
container._searchSelectDirty = false;
}
if (searchUrl) {
if (freeText) {
rebuildFreeTextRow(search.value.trim());
} else if (searchUrl) {
if (prefetch && !hasPrefetched) {
// Seed the window immediately on first open (not debounced).
hasPrefetched = true;
+4 -5
View File
@@ -6,6 +6,7 @@ from django.http import HttpResponse
from django.utils.safestring import SafeText, mark_safe
from common.components import Component, CsrfInput, Div, Input
from common.components.primitives import Td, Tr
from common.layout import render_page
@@ -15,12 +16,10 @@ def _login_content(form, request) -> SafeText:
children=[
CsrfInput(request),
mark_safe(str(form.as_table())),
Component(
tag_name="tr",
Tr(
children=[
Component(tag_name="td"),
Component(
tag_name="td",
Td(),
Td(
children=[
Input(type="submit", attributes=[("value", "Login")])
],
+27 -4
View File
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import (
A,
@@ -10,19 +11,28 @@ from common.components import (
ButtonGroup,
Icon,
paginated_table_content,
DeviceFilterBar,
ModuleScript,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
from games.filters import parse_device_filter
from games.forms import DeviceForm
from games.models import Device
@login_required
def list_devices(request: HttpRequest) -> HttpResponse:
devices, page_obj, elided_page_range = paginate(
request, Device.objects.order_by("-created_at")
)
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)
data = {
"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,
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
+30 -53
View File
@@ -2,15 +2,16 @@ from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.middleware.csrf import get_token
from django.db.models import Q
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.template.defaultfilters import date as date_filter
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from common.components import (
H1,
A,
AddForm,
Button,
@@ -21,9 +22,7 @@ from common.components import (
FilterBar,
GameStatus,
GameStatusSelector,
H1,
Icon,
SearchField,
LinkedPurchase,
Modal,
ModuleScript,
@@ -31,9 +30,12 @@ from common.components import (
Popover,
PopoverTruncated,
PurchasePrice,
SearchField,
SimpleTable,
Ul,
paginated_table_content,
)
from common.components.primitives import Li, P, Span, Strong
from common.icons import get_icon
from common.layout import render_page
from common.time import (
@@ -193,19 +195,13 @@ def _delete_game_confirmation_modal(
) -> SafeText:
data_items = []
if session_count:
data_items.append(
Component(tag_name="li", children=[f"{session_count} session(s)"])
)
data_items.append(Li(children=[f"{session_count} session(s)"]))
if purchase_count:
data_items.append(
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
)
data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
if playevent_count:
data_items.append(
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
)
data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
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(
tag_name="form",
@@ -218,8 +214,7 @@ def _delete_game_confirmation_modal(
],
children=[
CsrfInput(request),
Component(
tag_name="p",
P(
attributes=[
(
"class",
@@ -231,8 +226,7 @@ def _delete_game_confirmation_modal(
"This will permanently delete this game and all associated data:"
],
),
Component(
tag_name="ul",
Ul(
attributes=[
(
"class",
@@ -242,8 +236,7 @@ def _delete_game_confirmation_modal(
],
children=data_items,
),
Component(
tag_name="p",
P(
attributes=[
(
"class",
@@ -279,8 +272,7 @@ def _delete_game_confirmation_modal(
return Modal(
"delete-game-confirmation-modal",
children=[
Component(
tag_name="h1",
P(
attributes=[
(
"class",
@@ -289,12 +281,11 @@ def _delete_game_confirmation_modal(
],
children=["Delete Game"],
),
Component(
tag_name="p",
P(
attributes=[("class", "dark:text-white text-center mt-5")],
children=[
"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 = ""
) -> SafeText:
children: list[SafeText | str] = [
Component(
tag_name="span", attributes=[("class", "uppercase")], children=[label]
),
Span(attributes=[("class", "uppercase")], children=[label]),
value,
]
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:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
)
edit_link = Component(
tag_name="a",
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
edit_link = A(
href=reverse("games:edit_game", args=[game.id]),
children=[
Component(
tag_name="button",
@@ -463,10 +451,9 @@ def _game_action_buttons(game: Game) -> SafeText:
)
],
)
delete_link = Component(
tag_name="a",
delete_link = A(
href="#",
attributes=[
("href", "#"),
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
("hx-target", "#global-modal-container"),
],
@@ -499,21 +486,16 @@ def _game_history(statuschanges) -> SafeText:
status=change.new_status,
children=[change.get_new_status_display()],
)
edit = Component(
tag_name="a",
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
edit = A(
href=reverse("games:edit_statuschange", args=[change.id]),
children=["Edit"],
)
delete = Component(
tag_name="a",
attributes=[
("href", reverse("games:delete_statuschange", args=[change.id]))
],
delete = A(
href=reverse("games:delete_statuschange", args=[change.id]),
children=["Delete"],
)
items.append(
Component(
tag_name="li",
Li(
attributes=[("class", "text-slate-500")],
children=[
f"{prefix} status from ",
@@ -528,8 +510,7 @@ def _game_history(statuschanges) -> SafeText:
],
)
)
return Component(
tag_name="ul",
return Ul(
attributes=[("class", "list-disc list-inside")],
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:
grey_value_class = "text-black dark:text-slate-300"
title_span = Component(
tag_name="span",
title_span = Span(
attributes=[("class", "text-balance max-w-120 text-4xl")],
children=[
Component(
tag_name="span",
Span(
attributes=[("class", "font-bold font-serif")],
children=[game.name],
),
@@ -634,8 +613,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
[
_meta_row(
"Original year",
Component(
tag_name="span",
Span(
attributes=[("class", grey_value_class)],
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),
_meta_row(
"Platform",
Component(
tag_name="span",
Span(
attributes=[("class", grey_value_class)],
children=[str(game.platform)],
),
+27 -4
View File
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import (
A,
@@ -10,10 +11,13 @@ from common.components import (
ButtonGroup,
Icon,
paginated_table_content,
PlatformFilterBar,
ModuleScript,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
from games.filters import parse_platform_filter
from games.forms import PlatformForm
from games.models import Platform
from games.views.general import use_custom_redirect
@@ -21,9 +25,15 @@ from games.views.general import use_custom_redirect
@login_required
def list_platforms(request: HttpRequest) -> HttpResponse:
platforms, page_obj, elided_page_range = paginate(
request, Platform.objects.order_by("name")
)
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)
data = {
"header_action": A(
@@ -68,7 +78,20 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
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
+27 -4
View File
@@ -9,6 +9,8 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import (
A,
AddForm,
@@ -17,10 +19,12 @@ from common.components import (
Icon,
ModuleScript,
paginated_table_content,
PlayEventFilterBar,
)
from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime
from common.utils import paginate
from games.filters import parse_playevent_filter
from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session
@@ -126,9 +130,15 @@ def _get_formatted_playtime_for_game_sessions_in_range(
@login_required
def list_playevents(request: HttpRequest) -> HttpResponse:
playevents, page_obj, elided_page_range = paginate(
request, PlayEvent.objects.order_by("-created_at")
)
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)
data = create_playevent_tabledata(playevents, request=request)
content = paginated_table_content(
data,
@@ -136,7 +146,20 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
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
+17 -21
View File
@@ -6,13 +6,12 @@ from django.http import (
HttpResponseRedirect,
)
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 floatformat
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST
from common.components import (
A,
@@ -32,6 +31,7 @@ from common.components import (
TableRow,
paginated_table_content,
)
from common.components.primitives import Li, P, Td, Tr, Ul
from common.layout import render_page
from common.time import dateformat
from common.utils import paginate
@@ -129,7 +129,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
from common.components import PurchaseFilterBar, ModuleScript
from common.components import ModuleScript, PurchaseFilterBar
filter_bar = PurchaseFilterBar(
filter_json=filter_json,
@@ -149,12 +149,10 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
def _purchase_additional_row() -> SafeText:
"""The 'Submit & Create Session' row shown below the main Submit button."""
return Component(
tag_name="tr",
return Tr(
children=[
Component(tag_name="td"),
Component(
tag_name="td",
Td(),
Td(
children=[
Button(
[],
@@ -262,8 +260,7 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
Div(
[("class", row_class)],
[
Component(
tag_name="p",
P(
children=[
"Price per game: ",
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:"]),
Component(
tag_name="ul",
Ul(
children=[
Component(tag_name="li", children=[GameLink(game.id, game.name)])
Li(children=[GameLink(game.id, game.name)])
for game in purchase.games.all()
],
),
@@ -317,8 +313,7 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
],
children=[
CsrfInput(request),
Component(
tag_name="p",
P(
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
children=["Games will be marked as abandoned."],
),
@@ -356,8 +351,7 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
],
children=["Confirm Refund"],
),
Component(
tag_name="p",
P(
attributes=[("class", "dark:text-white text-center mt-5")],
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
form = PurchaseForm()
qs = related_purchase_queryset().filter(games__in=games).order_by(
"games__sort_name"
qs = (
related_purchase_queryset()
.filter(games__in=games)
.order_by("games__sort_name")
)
form.fields["related_purchase"].queryset = qs
+13 -22
View File
@@ -15,7 +15,6 @@ from common.components import (
AddForm,
Button,
ButtonGroup,
Component,
Div,
Icon,
ModuleScript,
@@ -25,6 +24,7 @@ from common.components import (
SessionDeviceSelector,
paginated_table_content,
)
from common.components.primitives import Span, Td, Tr
from common.layout import render_page
from common.time import (
dateformat,
@@ -208,8 +208,7 @@ def _session_fields(form) -> SafeText:
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
Component(
tag_name="span",
Span(
attributes=[
(
"class",
@@ -292,8 +291,8 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
def _session_row_fragment(session: Session) -> SafeText:
"""A single session <tr> (the old list_sessions.html#session-row partial),
returned by the inline end/clone-session HTMX endpoints."""
name_link = Component(
tag_name="a",
name_link = A(
href=reverse("games:view_game", args=[session.game.id]),
attributes=[
(
"class",
@@ -305,12 +304,10 @@ def _session_row_fragment(session: Session) -> SafeText:
"group-hover:outline-purple-400 group-hover:outline-4 "
"group-hover:decoration-purple-900 group-hover:text-purple-100",
),
("href", reverse("games:view_game", args=[session.game.id])),
],
children=[session.game.name],
)
name_td = Component(
tag_name="td",
name_td = Td(
attributes=[
(
"class",
@@ -319,15 +316,13 @@ def _session_row_fragment(session: Session) -> SafeText:
)
],
children=[
Component(
tag_name="span",
Span(
attributes=[("class", "inline-block relative")],
children=[name_link],
)
],
)
start_td = Component(
tag_name="td",
start_td = Td(
attributes=[
("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:
end_url = reverse("games:list_sessions_end_session", args=[session.id])
end_inner: SafeText | str = Component(
tag_name="a",
end_inner: SafeText | str = A(
href=end_url,
attributes=[
("href", end_url),
("hx-get", end_url),
("hx-target", "closest tr"),
("hx-swap", "outerHTML"),
@@ -351,8 +345,7 @@ def _session_row_fragment(session: Session) -> SafeText:
),
],
children=[
Component(
tag_name="span",
Span(
attributes=[("class", "text-yellow-300")],
children=["Finish now?"],
)
@@ -362,19 +355,17 @@ def _session_row_fragment(session: Session) -> SafeText:
end_inner = "--"
else:
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
end_td = Component(
tag_name="td",
end_td = Td(
attributes=[
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
],
children=[end_inner],
)
duration_td = Component(
tag_name="td",
duration_td = Td(
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
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:
+2 -2
View File
@@ -13,6 +13,7 @@ from common.components import (
Div,
paginated_table_content,
)
from common.components.primitives import P
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
@@ -75,8 +76,7 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
inner = Div(
[],
[
Component(
tag_name="p",
P(
children=["Are you sure you want to delete this status change?"],
),
Button(
+55
View File
@@ -821,5 +821,60 @@ class SimpleTableRenderingTest(unittest.TestCase):
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__":
unittest.main()
+177
View File
@@ -186,3 +186,180 @@ class FilterBarRenderingTest(TestCase):
self.assertNotIn("data-match=", html)
self.assertIn("Finished", 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)
+21 -1
View File
@@ -2,7 +2,7 @@
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):
@@ -66,3 +66,23 @@ class ParseBoolTest(SimpleTestCase):
def test_missing_value_in_field(self):
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
View File
@@ -8,6 +8,7 @@ from django.db.models import Q
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
DateCriterion,
IntCriterion,
Modifier,
MultiCriterion,
@@ -37,10 +38,34 @@ class TestStringCriterion:
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
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):
c = StringCriterion(value="", modifier=Modifier.IS_NULL)
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:
def test_between(self):
@@ -535,7 +560,8 @@ class TestFilterBarRendering:
def test_mastered_not_checked_by_default(self):
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):
html = str(
@@ -657,3 +683,555 @@ class TestPurchaseNumPurchasesAgainstDB:
)
result = set(Purchase.objects.filter(pf.to_q()))
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
+13
View File
@@ -60,3 +60,16 @@ class PathWorksTest(TestCase):
def test_list_purchases_returns_200(self):
response = self.client.get(reverse("games:list_purchases"))
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"})
+149
View File
@@ -290,3 +290,152 @@ class RenderedPagesTest(TestCase):
self.assertNoEscapedTags(html)
# The Python builder emits well-formed, balanced markup.
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)