Compare commits

..

1 Commits

Author SHA1 Message Date
lukas 21af7cddd0 Move from HTML templates to pure Python
Django CI/CD / test (push) Successful in 46s
Django CI/CD / build-and-push (push) Successful in 1m41s
2026-06-06 07:11:46 +02:00
25 changed files with 556 additions and 2920 deletions
-48
View File
@@ -1,48 +0,0 @@
with open("common/components.py", "r") as f:
content = f.read()
# Count FilterBar functions to know which replacement targets which
n = content.count('("name", "filter"), ("value", escape(filter_json))')
print(f"Found {n} hidden filter inputs")
# Simple: after each hidden filter input, insert a search input
search_html = ''' Component(tag_name="input", attributes=[
("type", "text"), ("name", "filter-search"),
("value", escape(search_val)),
("placeholder", "Search\u2026"),
("class", "block w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 "
"focus:ring-brand focus:border-brand"),
]),
'''
old = ''' Component(tag_name="input", attributes=[
("type", "hidden"), ("id", filter_input_id),
("name", "filter"), ("value", escape(filter_json)),
]),
Component(tag_name="div", attributes=['''
# Only replace occurrences in FilterBar functions (after 'def FilterBar' or 'def SessionFilterBar' or 'def PurchaseFilterBar')
# Find each occurrence and replace
import re
# Strategy: split by the old pattern, insert search_html between first two parts of each split
parts = content.split(old)
print(f"Split into {len(parts)} parts")
new_content = parts[0]
for i in range(1, len(parts)):
# Check if this occurrence is inside a FilterBar function (not inside SelectableFilter)
# Simple heuristic: the context before should contain 'FilterBar'
chunk_before = parts[i-1][-500:] if len(parts[i-1]) > 500 else parts[i-1]
is_filterbar = 'FilterBar' in chunk_before or 'filter_bar' in chunk_before.lower()
if is_filterbar:
new_content += old + search_html + parts[i]
else:
new_content += old + parts[i]
with open("common/components.py", "w") as f:
f.write(new_content)
import ast
ast.parse(new_content)
print("OK")
+46
View File
@@ -0,0 +1,46 @@
# Suggested Improvements to common/components.py
## Completed
### Caching on template rendering
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
- Verified working: identical calls return identical output, different inputs produce separate cache entries
### Non-deterministic IDs
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
- `games/templatetags/randomid.py` uses the same hash-based approach
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
### Inconsistent return types
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
### Fragile A() URL resolution
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
### Toast XSS vulnerability
The vulnerable `Toast()` component (which used unsafe string escaping for
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
headers → `show-toast` CustomEvent → Alpine store.
### Default mutable arguments
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
### NameWithIcon dead code and untestable design
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
### No tests
Zero test coverage for the entire component system.
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
and cache hit/miss verification.
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
+114 -553
View File
@@ -1,13 +1,15 @@
import hashlib import hashlib
import json
from functools import lru_cache from functools import lru_cache
from typing import Any from typing import Any
from django.conf import settings
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape, escape from django.utils.html import conditional_escape
from django.db import models
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.icons import get_icon from common.icons import get_icon
@@ -33,52 +35,53 @@ _SIZE_CLASSES = {
} }
@lru_cache(maxsize=4096) def _render_cached_impl(template: str, context_json: str) -> str:
def _render_element( context = json.loads(context_json)
tag_name: str, context["slot"] = mark_safe(context["slot"])
attrs_key: tuple[tuple[str, str], ...], return render_to_string(template, context)
children_key: tuple[tuple[str, bool], ...],
) -> str:
"""Pure, memoized HTML builder behind `Component`.
Inputs are fully hashable and fully determine the output, so identical
elements are rendered once. `attrs_key` is (name, stringified value) pairs if not settings.DEBUG:
(attribute values are always escaped). `children_key` is (child, is_safe) _render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
pairs: SafeText children pass through, plain strings are escaped. The else:
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"`` _render_cached = _render_cached_impl
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
render with the wrong escaping.
""" def enable_cache():
children_blob = "\n".join( """Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
child if is_safe else escape(child) for child, is_safe in children_key global _render_cached
) _render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
if attrs_key:
attributes_blob = " " + " ".join(
f'{name}="{escape(value)}"' for name, value in attrs_key
)
else:
attributes_blob = ""
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
def Component( def Component(
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: list[HTMLTag] | HTMLTag | None = None,
template: str = "",
tag_name: str = "", tag_name: str = "",
) -> SafeText: ) -> SafeText:
"""Render an HTML element. Attribute values are always escaped; children are
escaped unless they are `SafeText` (so nested components pass through),
preventing accidental HTML injection. Rendering is memoized via
`_render_element`."""
attributes = attributes or [] attributes = attributes or []
children = children or [] children = children or []
if not tag_name: if not tag_name and not template:
raise ValueError("tag_name is required.") raise ValueError("One of template or tag_name is required.")
if isinstance(children, str): if isinstance(children, str):
children = [children] children = [children]
attrs_key = tuple((name, str(value)) for name, value in attributes) childrenBlob = "\n".join(conditional_escape(child) for child in children)
children_key = tuple((child, isinstance(child, SafeText)) for child in children) if len(attributes) == 0:
return mark_safe(_render_element(tag_name, attrs_key, children_key)) attributesBlob = ""
else:
attributesList = [
f'{name}="{conditional_escape(str(value))}"' for name, value in attributes
]
attributesBlob = f" {' '.join(attributesList)}"
tag: str = ""
if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
elif template != "":
context = {name: value for name, value in attributes} | {
"slot": "\n".join(children)
}
tag = _render_cached(template, json.dumps(context, sort_keys=True))
return mark_safe(tag)
def randomid(seed: str = "", content: str = "", length: int = 10) -> str: def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
@@ -419,6 +422,21 @@ def Input(
) )
def Form(
action="",
method="get",
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(
tag_name="form",
attributes=attributes + [("action", action), ("method", method)],
children=children,
)
def CsrfInput(request) -> SafeText: def CsrfInput(request) -> SafeText:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.""" """Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
return mark_safe( return mark_safe(
@@ -786,6 +804,65 @@ def TableHeader(
) )
def Table(columns: list[str] | None = None, children=None) -> SafeText:
"""Standalone table with header and body slot.
Currently unused — superseded by simple_table. Kept for optional future use.
"""
columns = columns or []
children = children or []
return Component(
tag_name="div",
attributes=[("class", "relative overflow-x-auto shadow-md sm:rounded-lg")],
children=[
Component(
tag_name="table",
attributes=[
(
"class",
"w-full text-sm text-left rtl:text-right "
"text-gray-500 dark:text-gray-400",
),
],
children=[
Component(
tag_name="thead",
attributes=[
(
"class",
"text-xs text-gray-700 uppercase bg-gray-50 "
"dark:bg-gray-700 dark:text-gray-400",
),
],
children=[
Component(
tag_name="tr",
children=[
Component(
tag_name="th",
attributes=[
("scope", "col"),
("class", "px-6 py-3"),
],
children=[col],
)
for col in columns
],
),
],
),
Component(
tag_name="tbody",
children=(
children if isinstance(children, list) else [children]
),
),
],
),
],
)
def _page_url(request, page) -> str: def _page_url(request, page) -> str:
"""Current querystring with `page` replaced (mirrors {% param_replace %}).""" """Current querystring with `page` replaced (mirrors {% param_replace %})."""
if request is None: if request is None:
@@ -952,7 +1029,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())} {"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul> </ul>
""" """
icon = (purchase.platform.icon if purchase.platform else "unspecified") if game_count == 1 else "unspecified" icon = purchase.platform.icon if game_count == 1 else "unspecified"
if link_content == "": if link_content == "":
raise ValueError("link_content is empty!!") raise ValueError("link_content is empty!!")
a_content = Div( a_content = Div(
@@ -1172,519 +1249,3 @@ def _dropdown_button_html(button_content: str, list_items: str) -> str:
"</button>" "</button>"
"</div>" "</div>"
) )
# ── Filter bar ─────────────────────────────────────────────────────────────
def FilterBar(
filter_json: str = "",
status_options: list[tuple[str, str]] | None = None,
platform_options: list[tuple[int, str]] | None = None,
preset_list_url: str = "",
preset_save_url: str = "",
) -> "SafeText":
"""Render a collapsible filter bar with SelectableFilter widgets."""
from games.models import Game, Platform
if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status]
if platform_options is None:
platform_options = list(Platform.objects.order_by("name").values_list("id", "name"))
existing: dict = {}
if filter_json:
try:
import json
existing = json.loads(filter_json)
except (json.JSONDecodeError, TypeError):
pass
def _get_choice(field: str) -> tuple[list[str], list[str], str]:
raw = existing.get(field, {})
if not isinstance(raw, dict):
return [], [], ""
val = raw.get("value", [])
excl = raw.get("excludes", [])
mod = raw.get("modifier", "")
if isinstance(val, str):
val = [val]
if isinstance(excl, str):
excl = [excl]
return [str(v) for v in (val or [])], [str(v) for v in (excl or [])], mod or ""
status_sel, status_excl, status_mod = _get_choice("status")
plat_sel, plat_excl, plat_mod = _get_choice("platform")
plat_opts_str: list[tuple[str, str]] = [(str(k), v) for k, v in platform_options]
def _mins_to_hrs(val):
if val is None or val == "" or val == 0:
return ""
try:
mins = int(val)
except (TypeError, ValueError):
return ""
if mins == 0:
return ""
hrs = mins / 60
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
year_rel = existing.get("year_released", {})
year_min = str(year_rel.get("value", "")) if isinstance(year_rel, dict) else ""
year_max = str(year_rel.get("value2", "")) if isinstance(year_rel, dict) else ""
mastered_val = existing.get("mastered", {}).get("value", False) if isinstance(existing.get("mastered"), dict) else False
playtime = existing.get("playtime_minutes", {})
playtime_min = _mins_to_hrs(playtime.get("value", "")) if isinstance(playtime, dict) else ""
playtime_max = _mins_to_hrs(playtime.get("value2", "")) if isinstance(playtime, dict) else ""
# DB-backed ranges for sliders
try:
year_agg = Game.objects.aggregate(
yr_min=models.Min("year_released"), yr_max=models.Max("year_released")
)
except Exception:
year_agg = {}
try:
pt_agg = Game.objects.aggregate(
pt_max=models.Max("playtime")
)
except Exception:
pt_agg = {}
yr_data_min = max(int(year_agg.get("yr_min") or 1970), 1970)
yr_data_max = min(int(year_agg.get("yr_max") or 2030), 2030)
pt_data_max = int((pt_agg.get("pt_max") or 0).total_seconds() / 3600) if pt_agg.get("pt_max") else 200
form_id = "filter-bar-form"
filter_input_id = "filter-json-input"
def _number(label, name, value="", placeholder=""):
return Component(
tag_name="div",
attributes=[("class", "flex flex-col gap-1")],
children=[
Component(tag_name="label", attributes=[
("class", "text-xs font-medium text-body uppercase tracking-wide"),
], children=[label]),
Component(tag_name="input", attributes=[
("type", "number"), ("name", escape(name)), ("id", escape(name)),
("value", escape(value)), ("placeholder", escape(placeholder)),
("class", "block w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-2 "
"focus:ring-brand focus:border-brand"),
]),
],
)
def _range(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"):
mv = min_v or str(dmin)
xv = max_v or str(dmax)
return Component(
tag_name="div",
attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")],
children=[
mark_safe(
f'<input type="range" class="range-min absolute w-full pointer-events-none '
f'appearance-none bg-transparent h-2 '
f''
f'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 '
f'[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full '
f'[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer '
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10" '
f'data-target="{min_id}" data-peer="{max_id}" '
f'min="{dmin}" max="{dmax}" value="{mv}" step="{step}">'
f'<input type="range" class="range-max absolute w-full pointer-events-none '
f'appearance-none bg-transparent h-2 '
f''
f'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 '
f'[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full '
f'[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer '
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-20" '
f'data-target="{max_id}" data-peer="{min_id}" '
f'min="{dmin}" max="{dmax}" value="{xv}" step="{step}">'
),
],
)
return Component(
tag_name="div",
attributes=[("id", "filter-bar"), ("class", "mb-6")],
children=[
Component(tag_name="button", attributes=[
("type", "button"),
("onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()"),
("class", "flex items-center gap-2 text-sm font-medium text-body "
"hover:text-heading mb-2"),
], children=[
mark_safe('<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
'stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" '
'd="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 '
'1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 '
'1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'),
"Filters",
]),
Component(tag_name="div", attributes=[
("id", "filter-bar-body"),
("class", "hidden border border-default-medium rounded-base p-4 "
"bg-neutral-secondary-medium/50"),
], children=[
Component(tag_name="form", attributes=[
("id", form_id),
("onsubmit", "return applyFilterBar(event)"),
], children=[
Component(tag_name="input", attributes=[
("type", "hidden"), ("id", filter_input_id),
("name", "filter"), ("value", escape(filter_json)),
]),
Component(tag_name="div", attributes=[
("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"),
], children=[
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")],
children=[
Component(tag_name="label", attributes=[
("class", "text-xs font-medium text-body uppercase tracking-wide"),
], children=["Status"]),
SelectableFilter("status", status_options, status_sel, status_excl,
status_mod, nullable=not Game._meta.get_field("status").has_default()),
]),
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")],
children=[
Component(tag_name="label", attributes=[
("class", "text-xs font-medium text-body uppercase tracking-wide"),
], children=["Platform"]),
SelectableFilter("platform", plat_opts_str, plat_sel, plat_excl,
plat_mod, nullable=Game._meta.get_field("platform").null),
]),
_number("Year Min", "filter-year-min", year_min, "e.g. 2020"),
_number("Year Max", "filter-year-max", year_max, "e.g. 2024"),
]),
_range("year-range", "filter-year-min", "filter-year-max",
year_min, year_max, yr_data_min, yr_data_max),
Component(tag_name="div", attributes=[
("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"),
], children=[
_number("Playtime Min (hrs)", "filter-playtime-min", playtime_min, "e.g. 1"),
_number("Playtime Max (hrs)", "filter-playtime-max", playtime_max, "e.g. 100"),
Component(tag_name="div", attributes=[("class", "flex items-end pb-1")],
children=[
Component(tag_name="label", attributes=[
("class", "flex items-center gap-2 text-sm text-heading"),
], children=[
Component(tag_name="input", attributes=[
("type", "checkbox"), ("name", "filter-mastered"),
("value", "1"),
*([("checked", "true")] if mastered_val else []),
("class", "rounded border-default-medium "
"bg-neutral-secondary-medium text-brand focus:ring-brand"),
]),
"Mastered",
]),
]),
]),
_range("playtime-range", "filter-playtime-min", "filter-playtime-max",
playtime_min or "0", playtime_max or str(pt_data_max), 0, pt_data_max),
Component(tag_name="div", attributes=[
("class", "flex gap-3 items-center"),
], children=[
Component(tag_name="button", attributes=[
("type", "submit"),
("class", "px-4 py-2 text-sm font-medium text-white bg-brand "
"rounded-lg hover:bg-brand-strong focus:ring-4 "
"focus:ring-brand-medium"),
], children=["Apply"]),
Component(tag_name="button", attributes=[
("type", "button"),
("onclick", f"clearFilterBar('{form_id}', '{filter_input_id}')"),
("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white "
"border border-gray-200 rounded-lg hover:bg-gray-100 "
"dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 "
"dark:hover:bg-gray-700 dark:hover:text-white"),
], children=["Clear"]),
Component(tag_name="span", attributes=[
("class", "flex gap-2 items-center"), ("id", "save-preset-area"),
], children=[
Component(tag_name="input", attributes=[
("type", "text"), ("id", "preset-name-input"),
("placeholder", "Preset name..."),
("class", "hidden px-3 py-2 text-sm rounded-lg border "
"border-default-medium bg-neutral-secondary-medium "
"text-heading focus:ring-brand focus:border-brand"),
]),
Component(tag_name="button", attributes=[
("type", "button"), ("id", "save-preset-btn"),
("onclick", "showPresetNameInput()"),
("class", "px-4 py-2 text-sm font-medium text-gray-900 "
"bg-white border border-gray-200 rounded-lg "
"hover:bg-gray-100 dark:bg-gray-800 "
"dark:border-gray-600 dark:text-gray-400 "
"dark:hover:bg-gray-700 dark:hover:text-white"),
], children=["Save Preset"]),
Component(tag_name="button", attributes=[
("type", "button"), ("id", "confirm-save-preset-btn"),
("onclick", f"savePreset('{form_id}', '{filter_input_id}', '{preset_save_url}')"),
("class", "hidden px-4 py-2 text-sm font-medium text-white "
"bg-green-700 rounded-lg hover:bg-green-800 "
"focus:ring-4 focus:ring-green-300"),
], children=["Save"]),
]),
Component(tag_name="div", attributes=[
("id", "preset-dropdown"), ("class", "relative"),
("data-preset-list-url", preset_list_url),
], children=[
Component(tag_name="span", attributes=[
("class", "text-sm text-body"),
], children=["Loading presets..."]),
]),
]),
]),
]),
],
)
# ── SelectableFilter widget ────────────────────────────────────────────────
def SelectableFilter(
field_name: str,
options: list[tuple[str, str]],
selected: list[str] | None = None,
excluded: list[str] | None = None,
modifier: str = "",
nullable: bool = True,
) -> "SafeText":
"""Stash-style selectable filter with search, include/exclude, modifier tags."""
selected = selected or []
excluded = excluded or []
active_mod_html = ""
inactive_mod_html = ""
mod_opts = [("NOT_NULL", "(Any)")]
if nullable:
mod_opts.append(("IS_NULL", "(None)"))
for mod_val, mod_label in mod_opts:
if modifier == mod_val:
active_mod_html = (
f'<span class="sf-modifier-tag active" data-modifier="{mod_val}">'
f"{mod_label}</span> "
)
else:
inactive_mod_html += (
f'<div class="sf-option sf-modifier-option" data-modifier="{mod_val}" '
f'data-label="{mod_label}">'
f'<span class="sf-option-label">{mod_label}</span></div>'
)
selected_html = ""
for val in selected:
label = _find_label(options, val)
selected_html += (
f'<span class="sf-tag" data-value="{escape(val)}" data-type="include">'
f'<span class="sf-tag-text">\u2713 {escape(label)}</span>'
f'<button type="button" class="sf-remove">\u00d7</button></span> '
)
for val in excluded:
label = _find_label(options, val)
selected_html += (
f'<span class="sf-tag sf-excluded" data-value="{escape(val)}" data-type="exclude">'
f'<span class="sf-tag-text">\u2717 {escape(label)}</span>'
f'<button type="button" class="sf-remove">\u00d7</button></span> '
)
options_html = ""
for val, label in options:
options_html += (
f'<div class="sf-option" data-value="{escape(val)}" data-label="{escape(label)}">'
f'<span class="sf-option-label">{escape(label)}</span>'
f'<span class="sf-option-buttons">'
f'<button type="button" class="sf-btn-include" data-action="include" title="Include">+</button>'
f'<button type="button" class="sf-btn-exclude" data-action="exclude" title="Exclude">\u2212</button>'
f"</span></div>"
)
return Component(
tag_name="div",
attributes=[
("class", "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium"),
("data-selectable-filter", field_name),
*([("data-modifier", modifier)] if modifier else []),
],
children=[
Component(tag_name="div", attributes=[
("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"),
], children=[mark_safe(active_mod_html + selected_html)]),
Component(tag_name="input", attributes=[
("type", "text"),
("class", "sf-search block w-full border-0 border-t border-default-medium "
"bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden"),
("placeholder", "Search\u2026"),
]),
Component(tag_name="div", attributes=[
("class", "sf-options max-h-40 overflow-y-auto p-1"),
], children=[mark_safe(inactive_mod_html + options_html)]),
],
)
def _find_label(options: list[tuple[str, str]], value: str) -> str:
for v, label in options:
if str(v) == str(value):
return label
return value
def SessionFilterBar(filter_json="", preset_list_url="", preset_save_url=""):
from games.models import Game, Device, Session
game_opts = [(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")]
dev_opts = [(str(k), v) for k, v in Device.objects.order_by("name").values_list("id", "name")]
existing = {}
if filter_json:
try: import json; existing = json.loads(filter_json)
except Exception: pass
def _gc(f):
raw = existing.get(f, {})
if not isinstance(raw, dict): return [], [], ""
v = raw.get("value", []); e = raw.get("excludes", []); m = raw.get("modifier", "")
if isinstance(v, str): v = [v]
if isinstance(e, str): e = [e]
return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or ""
gs, ge, gm = _gc("game")
ds, de, dm = _gc("device")
def _mh(v):
if v is None or v == "" or v == 0: return ""
try: m = int(v)
except: return ""
if m == 0: return ""
h = m / 60; return str(int(h)) if h == int(h) else f"{h:.1f}"
dur = existing.get("duration_minutes", {})
dmin = _mh(dur.get("value", "")) if isinstance(dur, dict) else ""
dmax = _mh(dur.get("value2", "")) if isinstance(dur, dict) else ""
em = existing.get("emulated", {}).get("value", False) if isinstance(existing.get("emulated"), dict) else False
ac = existing.get("is_active", {}).get("value", False) if isinstance(existing.get("is_active"), dict) else False
try:
a = Session.objects.aggregate(m=models.Max("duration_total"))
ddm = max(int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1)
except Exception: ddm = 200
fd, hd = "filter-bar-form", "filter-json-input"
def _n(l, n, v="", p=""):
return Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[
Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=[l]),
Component(tag_name="input", attributes=[("type", "number"), ("name", escape(n)), ("id", escape(n)), ("value", escape(v)), ("placeholder", escape(p)), ("class", "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand")]),
])
def _r(cls, mi, mx, iv, xv, lo, hi, s="1"):
return Component(tag_name="div", attributes=[("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), ("data-min", str(lo)), ("data-max", str(hi)), ("data-step", str(s))], children=[
mark_safe('<div class="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-neutral-secondary-medium border border-default-medium"></div><div class="range-track-fill absolute top-1/2 -translate-y-1/2 h-2 bg-brand rounded-full" style="left:0;width:100%"></div>'+f'<div class="range-handle-min absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mi}" style="left:0"></div><div class="range-handle-max absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mx}" style="left:100%"></div>'),
])
return Component(tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[
Component(tag_name="button", attributes=[("type", "button"), ("onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()"), ("class", "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2")], children=[mark_safe('<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'), "Filters"]),
Component(tag_name="div", attributes=[("id", "filter-bar-body"), ("class", "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50")], children=[
Component(tag_name="form", attributes=[("id", fd), ("onsubmit", "return applyFilterBar(event)")], children=[
Component(tag_name="input", attributes=[("type", "hidden"), ("id", hd), ("name", "filter"), ("value", escape(filter_json))]),
Component(tag_name="div", attributes=[("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4")], children=[
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Game"]), SelectableFilter("game", game_opts, gs, ge, gm, nullable=not Game._meta.get_field("name").has_default())]),
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Device"]), SelectableFilter("device", dev_opts, ds, de, dm, nullable=Session._meta.get_field("device").null)]),
_n("Duration Min (hrs)", "filter-playtime-min", dmin, "e.g. 0.5"),
_n("Duration Max (hrs)", "filter-playtime-max", dmax, "e.g. 10"),
]),
_r("dur-range", "filter-playtime-min", "filter-playtime-max", dmin or "0", dmax or str(ddm), 0, ddm),
Component(tag_name="div", attributes=[("class", "flex gap-4 mb-4")], children=[
Component(tag_name="label", attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[Component(tag_name="input", attributes=[("type", "checkbox"), ("name", "filter-emulated"), ("value", "1"), *([("checked", "true")] if em else []), ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand")]), "Emulated"]),
Component(tag_name="label", attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[Component(tag_name="input", attributes=[("type", "checkbox"), ("name", "filter-active"), ("value", "1"), *([("checked", "true")] if ac else []), ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand")]), "Active"]),
]),
Component(tag_name="div", attributes=[("class", "flex gap-3 items-center")], children=[
Component(tag_name="button", attributes=[("type", "submit"), ("class", "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium")], children=["Apply"]),
Component(tag_name="button", attributes=[("type", "button"), ("onclick", f"clearFilterBar('{fd}', '{hd}')"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Clear"]),
Component(tag_name="span", attributes=[("class", "flex gap-2 items-center"), ("id", "save-preset-area")], children=[
Component(tag_name="input", attributes=[("type", "text"), ("id", "preset-name-input"), ("placeholder", "Preset name..."), ("class", "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand")]),
Component(tag_name="button", attributes=[("type", "button"), ("id", "save-preset-btn"), ("onclick", "showPresetNameInput()"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Save Preset"]),
Component(tag_name="button", attributes=[("type", "button"), ("id", "confirm-save-preset-btn"), ("onclick", f"savePreset('{fd}', '{hd}', '{preset_save_url}')"), ("class", "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300")], children=["Save"]),
]),
Component(tag_name="div", attributes=[("id", "preset-dropdown"), ("class", "relative"), ("data-preset-list-url", preset_list_url)], children=[Component(tag_name="span", attributes=[("class", "text-sm text-body")], children=["Loading presets..."])]),
]),
]),
]),
])
def PurchaseFilterBar(filter_json="", preset_list_url="", preset_save_url=""):
from games.models import Game, Platform, Purchase
game_opts = [(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")]
plat_opts = [(str(k), v) for k, v in Platform.objects.order_by("name").values_list("id", "name")]
type_opts = [(t[0], t[1]) for t in Purchase.TYPES]
own_opts = [(t[0], t[1]) for t in Purchase.OWNERSHIP_TYPES]
existing = {}
if filter_json:
try: import json; existing = json.loads(filter_json)
except Exception: pass
def _gc(f):
raw = existing.get(f, {})
if not isinstance(raw, dict): return [], [], ""
v = raw.get("value", []); e = raw.get("excludes", []); m = raw.get("modifier", "")
if isinstance(v, str): v = [v]
if isinstance(e, str): e = [e]
return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or ""
gs, ge, gm = _gc("games")
ps, pe, pm = _gc("platform")
ts, te, tm = _gc("type")
os, oe, om = _gc("ownership_type")
price = existing.get("price", {})
pmin = str(price.get("value", "")) if isinstance(price, dict) else ""
pmax = str(price.get("value2", "")) if isinstance(price, dict) else ""
rf = existing.get("is_refunded", {}).get("value", False) if isinstance(existing.get("is_refunded"), dict) else False
try:
a = Purchase.objects.aggregate(lo=models.Min("price"), hi=models.Max("price"))
plo, phi = int(a.get("lo") or 0), max(int(a.get("hi") or 100), 1)
except Exception: plo, phi = 0, 100
fd, hd = "filter-bar-form", "filter-json-input"
def _n(l, n, v="", p=""):
return Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[
Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=[l]),
Component(tag_name="input", attributes=[("type", "number"), ("name", escape(n)), ("id", escape(n)), ("value", escape(v)), ("placeholder", escape(p)), ("class", "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand")]),
])
def _r(cls, mi, mx, iv, xv, lo, hi, s="1"):
return Component(tag_name="div", attributes=[("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), ("data-min", str(lo)), ("data-max", str(hi)), ("data-step", str(s))], children=[
mark_safe('<div class="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-neutral-secondary-medium border border-default-medium"></div><div class="range-track-fill absolute top-1/2 -translate-y-1/2 h-2 bg-brand rounded-full" style="left:0;width:100%"></div>'+f'<div class="range-handle-min absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mi}" style="left:0"></div><div class="range-handle-max absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{mx}" style="left:100%"></div>'),
])
return Component(tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[
Component(tag_name="button", attributes=[("type", "button"), ("onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()"), ("class", "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2")], children=[mark_safe('<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'), "Filters"]),
Component(tag_name="div", attributes=[("id", "filter-bar-body"), ("class", "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50")], children=[
Component(tag_name="form", attributes=[("id", fd), ("onsubmit", "return applyFilterBar(event)")], children=[
Component(tag_name="input", attributes=[("type", "hidden"), ("id", hd), ("name", "filter"), ("value", escape(filter_json))]),
Component(tag_name="div", attributes=[("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4")], children=[
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Game"]), SelectableFilter("games", game_opts, gs, ge, gm, nullable=False)]),
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Platform"]), SelectableFilter("platform", plat_opts, ps, pe, pm, nullable=Purchase._meta.get_field("platform").null)]),
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Type"]), SelectableFilter("type", type_opts, ts, te, tm, nullable=not Purchase._meta.get_field("type").has_default())]),
Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Ownership"]), SelectableFilter("ownership_type", own_opts, os, oe, om, nullable=not Purchase._meta.get_field("ownership_type").has_default())]),
]),
Component(tag_name="div", attributes=[("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4")], children=[
_n("Price Min", "filter-price-min", pmin, "0.00"),
_n("Price Max", "filter-price-max", pmax, "100.00"),
Component(tag_name="label", attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[Component(tag_name="input", attributes=[("type", "checkbox"), ("name", "filter-refunded"), ("value", "1"), *([("checked", "true")] if rf else []), ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand")]), "Refunded"]),
]),
_r("price-range", "filter-price-min", "filter-price-max", pmin or str(plo), pmax or str(phi), plo, phi),
Component(tag_name="div", attributes=[("class", "flex gap-3 items-center")], children=[
Component(tag_name="button", attributes=[("type", "submit"), ("class", "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium")], children=["Apply"]),
Component(tag_name="button", attributes=[("type", "button"), ("onclick", f"clearFilterBar('{fd}', '{hd}')"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Clear"]),
Component(tag_name="span", attributes=[("class", "flex gap-2 items-center"), ("id", "save-preset-area")], children=[
Component(tag_name="input", attributes=[("type", "text"), ("id", "preset-name-input"), ("placeholder", "Preset name..."), ("class", "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand")]),
Component(tag_name="button", attributes=[("type", "button"), ("id", "save-preset-btn"), ("onclick", "showPresetNameInput()"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Save Preset"]),
Component(tag_name="button", attributes=[("type", "button"), ("id", "confirm-save-preset-btn"), ("onclick", f"savePreset('{fd}', '{hd}', '{preset_save_url}')"), ("class", "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300")], children=["Save"]),
]),
Component(tag_name="div", attributes=[("id", "preset-dropdown"), ("class", "relative"), ("data-preset-list-url", preset_list_url)], children=[Component(tag_name="span", attributes=[("class", "text-sm text-body")], children=["Loading presets..."])]),
]),
]),
]),
])
-451
View File
@@ -1,451 +0,0 @@
"""
Typed criterion inputs for building structured filters.
Inspired by Stash's filter architecture: every filterable field uses a typed
criterion with a value and a CriterionModifier. This separates *what* you're
filtering from *how* you're comparing, and makes filter serialization trivial.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field, fields as dc_fields
from enum import Enum
from typing import Any, Self, TypeVar
from django.db.models import Q
# ── Modifier ──────────────────────────────────────────────────────────────
class Modifier(str, Enum):
"""Comparison operators shared across all criterion types."""
EQUALS = "EQUALS"
NOT_EQUALS = "NOT_EQUALS"
GREATER_THAN = "GREATER_THAN"
LESS_THAN = "LESS_THAN"
BETWEEN = "BETWEEN"
NOT_BETWEEN = "NOT_BETWEEN"
INCLUDES = "INCLUDES"
EXCLUDES = "EXCLUDES"
INCLUDES_ALL = "INCLUDES_ALL"
IS_NULL = "IS_NULL"
NOT_NULL = "NOT_NULL"
MATCHES_REGEX = "MATCHES_REGEX"
NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
@classmethod
def for_strings(cls) -> list[Self]:
return [
cls.EQUALS, cls.NOT_EQUALS,
cls.INCLUDES, cls.EXCLUDES,
cls.MATCHES_REGEX, cls.NOT_MATCHES_REGEX,
cls.IS_NULL, cls.NOT_NULL,
]
@classmethod
def for_numbers(cls) -> list[Self]:
return [
cls.EQUALS, cls.NOT_EQUALS,
cls.GREATER_THAN, cls.LESS_THAN,
cls.BETWEEN, cls.NOT_BETWEEN,
cls.IS_NULL, cls.NOT_NULL,
]
@classmethod
def for_dates(cls) -> list[Self]:
return cls.for_numbers()
@classmethod
def for_multi(cls) -> list[Self]:
return [
cls.INCLUDES, cls.EXCLUDES,
cls.INCLUDES_ALL,
cls.IS_NULL, cls.NOT_NULL,
]
# ── Base criterion ─────────────────────────────────────────────────────────
T = TypeVar("T")
@dataclass
class _Criterion:
"""Base for all typed criteria."""
value: Any = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
raise NotImplementedError
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name in data:
val = data[f.name]
# Coerce string modifier to Modifier enum
if f.name == "modifier" and isinstance(val, str):
val = Modifier(val)
kwargs[f.name] = val
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is not None and v != f.default:
result[f.name] = v
return result
# ── Concrete criteria ──────────────────────────────────────────────────────
@dataclass
class StringCriterion(_Criterion):
value: str = ""
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.INCLUDES:
return Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.MATCHES_REGEX:
return Q(**{f"{field_name}__regex": self.value})
if m == Modifier.NOT_MATCHES_REGEX:
return ~Q(**{f"{field_name}__regex": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for string field")
@dataclass
class IntCriterion(_Criterion):
value: int = 0
value2: int | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2)})
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for int field")
@dataclass
class FloatCriterion(_Criterion):
value: float = 0.0
value2: float | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2)})
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for float field")
@dataclass
class DateCriterion(_Criterion):
value: str = ""
value2: str | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": self.value,
f"{field_name}__lte": self.value2})
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
return Q(**{f"{field_name}__lt": self.value}) | Q(**{f"{field_name}__gt": self.value2})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for date field")
@dataclass
class BoolCriterion(_Criterion):
value: bool = False
# Bool only makes sense with EQUALS
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
if self.modifier == Modifier.EQUALS:
return Q(**{field_name: self.value})
if self.modifier == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
@dataclass
class MultiCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.INCLUDES_ALL:
q = Q()
for v in self.value:
q &= Q(**{field_name: v})
return q
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for multi field")
@dataclass
class ChoiceCriterion(_Criterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by SelectableFilter widgets for status, ownership_type, etc.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.INCLUDES:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EXCLUDES:
q = Q()
if self.value:
q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.EQUALS:
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for choice field")
# ── OperatorFilter base ────────────────────────────────────────────────────
F = TypeVar("F", bound="OperatorFilter")
@dataclass
class OperatorFilter:
"""Mixin providing AND/OR/NOT composition for entity filter types.
Subclasses should declare nullable references to themselves::
@dataclass
class GameFilter(OperatorFilter):
AND: "GameFilter | None" = None
OR: "GameFilter | None" = None
NOT: "GameFilter | None" = None
name: StringCriterion | None = None
...
"""
def sub_filter(self) -> OperatorFilter | None:
"""Return the first non-None of AND / OR / NOT."""
for attr in ("AND", "OR", "NOT"):
if hasattr(self, attr):
v = getattr(self, attr)
if v is not None:
return v
return None
def _criterion_fields(self) -> list[str]:
"""Return field names that hold a _Criterion instance."""
names: list[str] = []
for f in dc_fields(self):
if f.name in ("AND", "OR", "NOT"):
continue
v = getattr(self, f.name)
if isinstance(v, _Criterion):
names.append(f.name)
return names
def to_q(self) -> Q:
"""Build a Django Q object from this filter and its sub-filters."""
q = Q()
for field_name in self._criterion_fields():
c = getattr(self, field_name)
if c is not None:
q &= c.to_q(field_name)
sub = self.sub_filter()
if sub is not None:
if getattr(self, "AND", None) is not None:
q &= sub.to_q()
elif getattr(self, "OR", None) is not None:
q |= sub.to_q()
elif getattr(self, "NOT", None) is not None:
q &= ~sub.to_q()
return q
@classmethod
def from_json(cls, data: dict[str, Any] | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
# Resolve criterion class names to actual types
criterion_types: dict[str, type[_Criterion]] = {
"StringCriterion": StringCriterion,
"IntCriterion": IntCriterion,
"FloatCriterion": FloatCriterion,
"DateCriterion": DateCriterion,
"BoolCriterion": BoolCriterion,
"MultiCriterion": MultiCriterion,
"ChoiceCriterion": ChoiceCriterion,
}
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name not in data:
continue
raw = data[f.name]
if raw is None:
kwargs[f.name] = None
continue
# Recurse into sub-filters (AND / OR / NOT)
if f.name in ("AND", "OR", "NOT"):
kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
continue
# Resolve criterion fields from string type annotation
f_type = f.type
if isinstance(f_type, str):
# e.g. "StringCriterion | None" → "StringCriterion"
f_type = f_type.split("|")[0].strip()
if isinstance(f_type, str) and f_type in criterion_types:
criterion_cls = criterion_types[f_type]
kwargs[f.name] = criterion_cls.from_json(raw) if isinstance(raw, dict) else None
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
kwargs[f.name] = f_type.from_json(raw) if isinstance(raw, dict) else None
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is None:
continue
if f.name in ("AND", "OR", "NOT"):
result[f.name] = v.to_json()
elif isinstance(v, _Criterion):
j = v.to_json()
if j:
result[f.name] = j
return result
# ── JSON helpers ───────────────────────────────────────────────────────────
def filter_from_json(cls: type[F], json_str: str) -> F | None:
"""Deserialize a filter from a JSON string.
Usage:
f = filter_from_json(GameFilter, request.GET.get("filter", ""))
games = Game.objects.filter(f.to_q())
"""
if not json_str:
return None
try:
data = json.loads(json_str)
except json.JSONDecodeError:
return None
return cls.from_json(data)
def filter_to_json(f: OperatorFilter) -> str:
"""Serialize a filter to a JSON string for URL params or storage."""
return json.dumps(f.to_json())
-23
View File
@@ -5,34 +5,11 @@ from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode from urllib.parse import urlencode
from django.core.paginator import Page, Paginator
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import redirect from django.shortcuts import redirect
def paginate(request: HttpRequest, queryset, per_page: int = 10):
"""Standard list-view pagination.
Reads ``page`` and ``limit`` from the query string (``limit=0`` disables
pagination) and returns ``(object_list, page_obj, elided_page_range)`` ready
to hand to ``paginated_table_content``.
"""
page_number = request.GET.get("page", 1)
limit = int(request.GET.get("limit", per_page))
object_list = queryset
page_obj: Page | None = None
if limit != 0:
page_obj = Paginator(queryset, limit).get_page(page_number)
object_list = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
return object_list, page_obj, elided_page_range
def safe_division(numerator: int | float, denominator: int | float) -> int | float: def safe_division(numerator: int | float, denominator: int | float) -> int | float:
""" """
Divides without triggering division by zero exception. Divides without triggering division by zero exception.
-384
View File
@@ -1,384 +0,0 @@
"""
Entity-specific filter types for the timetracker app.
Each filter class mirrors a Django model, with fields expressed as typed
criteria from common.criteria. The to_q() method produces a Django Q object
ready for queryset.filter().
Inspired by Stash's filter architecture: each entity has an OperatorFilter
with AND/OR/NOT composition and typed criterion fields.
"""
from __future__ import annotations
from dataclasses import dataclass
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
FloatCriterion,
IntCriterion,
Modifier,
MultiCriterion,
OperatorFilter,
StringCriterion,
filter_from_json,
)
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
@dataclass
class FindFilter:
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
q: str | None = None # free-text search
page: int = 1
per_page: int = 25
sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc
# ── GameFilter ─────────────────────────────────────────────────────────────
@dataclass
class GameFilter(OperatorFilter):
"""Filter for the Game model."""
AND: GameFilter | None = None
OR: GameFilter | None = None
NOT: GameFilter | None = None
name: StringCriterion | None = None
sort_name: StringCriterion | None = None
year_released: IntCriterion | None = None
original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget
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
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
q = Q()
# ── individual criteria ──
if self.name is not None:
q &= self.name.to_q("name")
if self.sort_name is not None:
q &= self.sort_name.to_q("sort_name")
if self.year_released is not None:
q &= self.year_released.to_q("year_released")
if self.original_year_released is not None:
q &= self.original_year_released.to_q("original_year_released")
if self.wikidata is not None:
q &= self.wikidata.to_q("wikidata")
if self.platform is not None:
q &= self.platform.to_q("platform_id")
if self.status is not None:
q &= self.status.to_q("status")
if self.mastered is not None:
q &= self.mastered.to_q("mastered")
if self.playtime_minutes is not None:
q &= self._playtime_to_q(self.playtime_minutes)
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
# ── free-text search (OR across multiple fields) ──
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(sort_name__icontains=self.search.value)
| Q(platform__name__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# ── AND / OR / NOT sub-filters ──
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
@staticmethod
def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
minutes → timedelta(microseconds=X) and use the appropriate lookups.
"""
from datetime import timedelta
from common.criteria import Modifier
from django.db.models import Q
m = c.modifier
field = "playtime"
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()
# ── SessionFilter ──────────────────────────────────────────────────────────
@dataclass
class SessionFilter(OperatorFilter):
"""Filter for the Session model."""
AND: SessionFilter | None = None
OR: SessionFilter | None = None
NOT: SessionFilter | None = None
game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from datetime import timedelta
from django.db.models import Q
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.device is not None:
q &= self.device.to_q("device_id")
if self.emulated is not None:
q &= self.emulated.to_q("emulated")
if self.note is not None:
q &= self.note.to_q("note")
if self.duration_minutes is not None:
c = self.duration_minutes
td_val = timedelta(minutes=c.value)
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)})
if self.is_active is not None:
if self.is_active.value:
q &= Q(timestamp_end__isnull=True)
else:
q &= Q(timestamp_end__isnull=False)
if self.timestamp_start is not None:
q &= self.timestamp_start.to_q("timestamp_start")
if self.timestamp_end is not None:
q &= self.timestamp_end.to_q("timestamp_end")
if self.is_manual is not None:
if self.is_manual.value:
q &= ~Q(duration_manual=timedelta(0))
else:
q &= Q(duration_manual=timedelta(0))
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(game__platform__name__icontains=self.search.value)
| Q(device__name__icontains=self.search.value)
| Q(device__type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: sessions for games matching GameFilter
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)
# AND / OR / NOT
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
# ── PurchaseFilter ─────────────────────────────────────────────────────────
@dataclass
class PurchaseFilter(OperatorFilter):
"""Filter for the Purchase model."""
AND: PurchaseFilter | None = None
OR: PurchaseFilter | None = None
NOT: PurchaseFilter | None = None
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
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None
price_currency: StringCriterion | None = None
num_purchases: IntCriterion | None = None
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.platform is not None:
q &= self.platform.to_q("platform_id")
if self.games is not None:
q &= self.games.to_q("games")
if self.date_purchased is not None:
q &= self.date_purchased.to_q("date_purchased")
if self.date_refunded is not None:
q &= self.date_refunded.to_q("date_refunded")
if self.is_refunded is not None:
q &= Q(date_refunded__isnull=not self.is_refunded.value)
if self.price is not None:
q &= self.price.to_q("price")
if self.converted_price is not None:
q &= self.converted_price.to_q("converted_price")
if self.price_currency is not None:
q &= self.price_currency.to_q("price_currency")
if self.num_purchases is not None:
q &= self.num_purchases.to_q("num_purchases")
if self.ownership_type is not None:
q &= self.ownership_type.to_q("ownership_type")
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")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(games__name__icontains=self.search.value)
| Q(platform__name__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity 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(games__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 ────────────────────────────────────────────────────
def parse_game_filter(json_str: str) -> GameFilter | None:
return filter_from_json(GameFilter, json_str)
def parse_session_filter(json_str: str) -> SessionFilter | None:
return filter_from_json(SessionFilter, json_str)
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
return filter_from_json(PurchaseFilter, json_str)
+1 -2
View File
@@ -43,7 +43,7 @@ class SessionForm(forms.ModelForm):
), ),
label="Manual duration", label="Manual duration",
) )
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False) device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
mark_as_played = forms.BooleanField( mark_as_played = forms.BooleanField(
required=False, required=False,
@@ -104,7 +104,6 @@ class PurchaseForm(forms.ModelForm):
"hx-swap": "outerHTML", "hx-swap": "outerHTML",
} }
) )
self.fields["platform"].queryset = Platform.objects.order_by("name")
games = MultipleGameChoiceField( games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
@@ -1,29 +0,0 @@
# Generated by Django 6.0.1 on 2026-06-06 07:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0016_add_needs_price_update'),
]
operations = [
migrations.CreateModel(
name='FilterPreset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)),
('find_filter', models.JSONField(blank=True, default=dict)),
('object_filter', models.JSONField(blank=True, default=dict)),
('ui_options', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
]
-30
View File
@@ -478,33 +478,3 @@ class GameStatusChange(models.Model):
class Meta: class Meta:
ordering = ["-timestamp"] ordering = ["-timestamp"]
class FilterPreset(models.Model):
"""Saved filter configuration, following Stash's SavedFilter pattern.
Separates find_filter (sort/pagination), object_filter (criteria JSON),
and ui_options (presentation state) so they can evolve independently.
"""
class Meta:
ordering = ["name"]
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
]
name = models.CharField(max_length=255)
mode = models.CharField(max_length=50, choices=MODE_CHOICES, default="games")
find_filter = models.JSONField(default=dict, blank=True)
object_filter = models.JSONField(default=dict, blank=True)
ui_options = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.name} ({self.get_mode_display()})"
+2 -161
View File
@@ -826,9 +826,6 @@
.top-0 { .top-0 {
top: calc(var(--spacing) * 0); top: calc(var(--spacing) * 0);
} }
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
.top-3 { .top-3 {
top: calc(var(--spacing) * 3); top: calc(var(--spacing) * 3);
} }
@@ -1276,9 +1273,6 @@
margin-left: -10px !important; margin-left: -10px !important;
} }
} }
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
.ml-auto { .ml-auto {
margin-left: auto; margin-left: auto;
} }
@@ -1437,9 +1431,6 @@
width: calc(var(--spacing) * 6); width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6);
} }
.h-2 {
height: calc(var(--spacing) * 2);
}
.h-2\.5 { .h-2\.5 {
height: calc(var(--spacing) * 2.5); height: calc(var(--spacing) * 2.5);
} }
@@ -1470,15 +1461,9 @@
.h-full { .h-full {
height: 100%; height: 100%;
} }
.max-h-40 {
max-height: calc(var(--spacing) * 40);
}
.max-h-full { .max-h-full {
max-height: 100%; max-height: 100%;
} }
.min-h-\[28px\] {
min-height: 28px;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -1671,10 +1656,6 @@
--tw-translate-x: 100%; --tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
} }
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-full { .-translate-y-full {
--tw-translate-y: -100%; --tw-translate-y: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1713,12 +1694,6 @@
.list-disc { .list-disc {
list-style-type: disc; list-style-type: disc;
} }
.appearance-none {
appearance: none;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-4 { .grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@@ -1851,9 +1826,6 @@
.rounded-base { .rounded-base {
border-radius: var(--radius-base); border-radius: var(--radius-base);
} }
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg { .rounded-lg {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
@@ -1916,10 +1888,6 @@
border-style: var(--tw-border-style) !important; border-style: var(--tw-border-style) !important;
border-width: 0px !important; border-width: 0px !important;
} }
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-e { .border-e {
border-inline-end-style: var(--tw-border-style); border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 1px; border-inline-end-width: 1px;
@@ -2016,15 +1984,9 @@
.border-red-200 { .border-red-200 {
border-color: var(--color-red-200); border-color: var(--color-red-200);
} }
.border-red-500 {
border-color: var(--color-red-500);
}
.border-transparent { .border-transparent {
border-color: transparent; border-color: transparent;
} }
.border-white {
border-color: var(--color-white);
}
.apexcharts-active { .apexcharts-active {
.apexcharts-canvas .apexcharts-tooltip-series-group& .apexcharts-tooltip-y-group { .apexcharts-canvas .apexcharts-tooltip-series-group& .apexcharts-tooltip-y-group {
padding: 0 !important; padding: 0 !important;
@@ -2128,12 +2090,6 @@
.bg-neutral-secondary-medium { .bg-neutral-secondary-medium {
background-color: var(--color-neutral-secondary-medium); background-color: var(--color-neutral-secondary-medium);
} }
.bg-neutral-secondary-medium\/50 {
background-color: color-mix(in srgb, oklch(98.5% 0.002 247.839) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-neutral-secondary-medium) 50%, transparent);
}
}
.bg-neutral-tertiary-medium { .bg-neutral-tertiary-medium {
background-color: var(--color-neutral-tertiary-medium); background-color: var(--color-neutral-tertiary-medium);
} }
@@ -2331,9 +2287,6 @@
color: heading !important; color: heading !important;
} }
} }
.pb-1 {
padding-bottom: calc(var(--spacing) * 1);
}
.pb-16 { .pb-16 {
padding-bottom: calc(var(--spacing) * 16); padding-bottom: calc(var(--spacing) * 16);
} }
@@ -2493,10 +2446,6 @@
--tw-tracking: var(--tracking-tight); --tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
} }
.tracking-wide {
--tw-tracking: var(--tracking-wide);
letter-spacing: var(--tracking-wide);
}
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
@@ -2551,9 +2500,6 @@
.text-body { .text-body {
color: var(--color-body); color: var(--color-body);
} }
.text-brand {
color: var(--color-brand);
}
.text-fg-brand { .text-fg-brand {
color: var(--color-fg-brand); color: var(--color-fg-brand);
} }
@@ -2623,9 +2569,6 @@
.uppercase { .uppercase {
text-transform: uppercase; text-transform: uppercase;
} }
.italic {
font-style: italic;
}
.no-underline\! { .no-underline\! {
text-decoration-line: none !important; text-decoration-line: none !important;
} }
@@ -2720,10 +2663,6 @@
--tw-ease: var(--ease-out); --tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out); transition-timing-function: var(--ease-out);
} }
.select-none {
-webkit-user-select: none;
user-select: none;
}
.\[program\:caddy\] { .\[program\:caddy\] {
program: caddy; program: caddy;
} }
@@ -2853,16 +2792,6 @@
background-color: var(--color-gray-50); background-color: var(--color-gray-50);
} }
} }
.hover\:scale-110 {
&:hover {
@media (hover: hover) {
--tw-scale-x: 110%;
--tw-scale-y: 110%;
--tw-scale-z: 110%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
}
}
.hover\:cursor-pointer { .hover\:cursor-pointer {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -2933,13 +2862,6 @@
} }
} }
} }
.hover\:bg-neutral-secondary-medium {
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-medium);
}
}
}
.hover\:bg-neutral-tertiary-medium { .hover\:bg-neutral-tertiary-medium {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -3045,13 +2967,6 @@
} }
} }
} }
.hover\:text-red-700 {
&:hover {
@media (hover: hover) {
color: var(--color-red-700);
}
}
}
.hover\:text-white { .hover\:text-white {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -3074,12 +2989,6 @@
color: var(--color-blue-700); color: var(--color-blue-700);
} }
} }
.focus\:ring-0 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + 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\:ring-2 { .focus\:ring-2 {
&:focus { &:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@@ -3173,9 +3082,9 @@
max-width: var(--container-xl); max-width: var(--container-xl);
} }
} }
.sm\:grid-cols-2 { .sm\:rounded-lg {
@media (width >= 40rem) { @media (width >= 40rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); border-radius: var(--radius-lg);
} }
} }
.sm\:rounded-t-lg { .sm\:rounded-t-lg {
@@ -3323,11 +3232,6 @@
max-width: var(--container-3xl); max-width: var(--container-3xl);
} }
} }
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.xl\:max-w-\(--breakpoint-xl\) { .xl\:max-w-\(--breakpoint-xl\) {
@media (width >= 80rem) { @media (width >= 80rem) {
max-width: var(--breakpoint-xl); max-width: var(--breakpoint-xl);
@@ -3895,51 +3799,6 @@
} }
} }
} }
.\[\&\:\:-webkit-slider-thumb\]\:relative {
&::-webkit-slider-thumb {
position: relative;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:z-10 {
&::-webkit-slider-thumb {
z-index: 10;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:z-20 {
&::-webkit-slider-thumb {
z-index: 20;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:h-4 {
&::-webkit-slider-thumb {
height: calc(var(--spacing) * 4);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:w-4 {
&::-webkit-slider-thumb {
width: calc(var(--spacing) * 4);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:cursor-pointer {
&::-webkit-slider-thumb {
cursor: pointer;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:appearance-none {
&::-webkit-slider-thumb {
appearance: none;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:rounded-full {
&::-webkit-slider-thumb {
border-radius: calc(infinity * 1px);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:bg-brand {
&::-webkit-slider-thumb {
background-color: var(--color-brand);
}
}
.\[\&\:first-of-type_button\]\:rounded-s-lg { .\[\&\:first-of-type_button\]\:rounded-s-lg {
&:first-of-type button { &:first-of-type button {
border-start-start-radius: var(--radius-lg); border-start-start-radius: var(--radius-lg);
@@ -5173,21 +5032,6 @@ form input:disabled, select:disabled, textarea:disabled {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-scale-x {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-y {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-z {
syntax: "*";
inherits: false;
initial-value: 1;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -5255,9 +5099,6 @@ form input:disabled, select:disabled, textarea:disabled {
--tw-backdrop-sepia: initial; --tw-backdrop-sepia: initial;
--tw-duration: initial; --tw-duration: initial;
--tw-ease: initial; --tw-ease: initial;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-scale-z: 1;
} }
} }
} }
-380
View File
@@ -1,380 +0,0 @@
/**
* Filter bar vanilla JavaScript implementation.
*
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX plain fetch() and window.location for all interactions.
*/
(function () {
"use strict";
/** Build a criterion object from a value and optional second value. */
function criterion(value, value2, modifier) {
var c = { value: value, modifier: modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
c.value2 = value2;
}
return c;
}
/** Read a <select> element's value, or "" if not found. */
function selectValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : "";
}
/** Read an <input type="number"> value, or "" if not found. */
function numberValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
if (!el || el.value === "") return "";
var val = parseFloat(el.value);
return isNaN(val) ? "" : val;
}
/** Read all checked checkboxes with a given name, returning an array of ints. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
var ids = [];
els.forEach(function (el) {
var v = parseInt(el.value, 10);
if (!isNaN(v)) ids.push(v);
});
return ids;
}
/**
* Build the filter JSON object from form field values.
* Returns a plain object ready for JSON.stringify.
*/
function buildFilterJSON(form) {
// Read all SelectableFilter widgets first
readSelectableFilters(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"]');
if (searchInput && searchInput.value.trim()) {
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── Generic SelectableFilter widgets ──
readSelectableFilters(form);
var widgets = form.querySelectorAll("[data-selectable-filter]");
widgets.forEach(function (w) {
var field = w.getAttribute("data-selectable-filter");
var inc = parseJSONAttr(w, "data-included");
var exc = parseJSONAttr(w, "data-excluded");
var mod = w.getAttribute("data-modifier");
if (mod === "NOT_NULL" || mod === "IS_NULL") {
filter[field] = { modifier: mod };
} else if (inc.length > 0 || exc.length > 0) {
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
filter[field] = {
value: isIdField ? inc.map(Number) : inc,
excludes: isIdField ? exc.map(Number) : exc,
modifier: mod || "INCLUDES",
};
}
});
// ── Session-specific fields ──
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
// Game (sessions page)
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
if (gameWidget) {
var gIncluded = parseJSONAttr(gameWidget, "data-included");
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
var gMod = gameWidget.getAttribute("data-modifier");
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
filter.game = { modifier: gMod };
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
filter.game = {
value: gIncluded.map(Number),
excludes: gExcluded.map(Number),
modifier: gMod || "INCLUDES",
};
}
}
// Device (sessions page)
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
if (deviceWidget) {
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
var dMod = deviceWidget.getAttribute("data-modifier");
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
filter.device = { modifier: dMod };
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
filter.device = {
value: dIncluded.map(Number),
excludes: dExcluded.map(Number),
modifier: dMod || "INCLUDES",
};
}
}
// 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 !== "") {
// Skip if both equal the data range extremes (no real filter)
var yrMinNum = parseInt(yearMin, 10);
var yrMaxNum = parseInt(yearMax, 10);
if (yrMinNum === yrMaxNum) {
// don't add filter
} else {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
}
} else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") {
filter.year_released = criterion(yearMax, null, "LESS_THAN");
}
if (playMin !== "" || playMax !== "") {
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
// Skip if both are 0 — means slider is at default (no real filter)
if (pMin === 0 && pMax === 0) {
// don't add filter
} else {
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");
}
}
}
if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS");
}
return filter;
}
/** Extract the current page's base URL (without query string). */
function baseUrl() {
return window.location.pathname;
}
/** Safely parse a JSON attribute, returning empty array on failure. */
function parseJSONAttr(el, attr) {
var raw = el.getAttribute(attr);
if (!raw) return [];
try { return JSON.parse(raw); } catch (e) { return []; }
}
/**
* Called on filter bar form submit.
* Serializes filter fields, navigates to URL with filter param.
*/
window.applyFilterBar = function (event) {
event.preventDefault();
var form = event.target;
var filter = buildFilterJSON(form);
var filterStr = JSON.stringify(filter);
var url = baseUrl();
if (filterStr && filterStr !== "{}") {
url += "?filter=" + encodeURIComponent(filterStr);
}
window.location.href = url;
return false;
};
/**
* Clear all filter fields and reload the unfiltered view.
*/
window.clearFilterBar = function (formId, filterInputId) {
var form = document.getElementById(formId);
if (!form) return;
form.reset();
window.location.href = baseUrl();
};
// ── Presets ─────────────────────────────────────────────────────────────
/** Fetch and render the preset list. */
function loadPresets() {
var dropdown = document.getElementById("preset-dropdown");
if (!dropdown) return;
var url = dropdown.getAttribute("data-preset-list-url");
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";
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to load presets");
return r.text();
})
.then(function (html) {
dropdown.innerHTML = html;
// Re-attach delete handlers (list_presets view uses onclick attributes,
// but we also need to wire up inline handlers if they use data attributes)
setupPresetDeleteHandlers(dropdown);
})
.catch(function (err) {
dropdown.innerHTML =
'<span class="text-sm text-body italic">Presets unavailable</span>';
console.error(err);
});
}
/** Wire up click handlers for preset delete buttons. */
function setupPresetDeleteHandlers(container) {
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
deleteLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var presetId = link.getAttribute("data-delete-preset");
var deleteUrl = link.getAttribute("href");
if (!deleteUrl) return;
if (!confirm("Delete this preset?")) return;
fetch(deleteUrl, {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRFToken": getCsrfToken() },
})
.then(function () {
// Remove the parent <li>
var li = link.closest("li");
if (li) li.remove();
// If no items left, show empty message
var ul = container.querySelector("ul");
if (ul && ul.querySelectorAll("li").length === 0) {
ul.innerHTML =
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
}
})
.catch(function (err) {
console.error("Delete failed:", err);
});
});
});
}
/** Show the preset name input field and the confirm button. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (input) input.classList.remove("hidden");
if (saveBtn) saveBtn.classList.add("hidden");
if (confirmBtn) confirmBtn.classList.remove("hidden");
if (input) input.focus();
};
/** Save the current filter as a named preset. */
window.savePreset = function (formId, filterInputId, saveUrl) {
var input = document.getElementById("preset-name-input");
var name = input ? input.value.trim() : "";
if (!name) {
if (input) input.classList.add("border-red-500");
return;
}
var filterInput = document.getElementById(filterInputId);
var form = document.getElementById(formId);
var filterObj = form ? buildFilterJSON(form) : {};
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";
body.append("mode", mode);
body.append("filter", JSON.stringify(filterObj));
fetch(saveUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCsrfToken(),
},
body: body.toString(),
})
.then(function (r) {
if (!r.ok) throw new Error("Save failed");
// Reset UI
if (input) {
input.value = "";
input.classList.add("hidden");
input.classList.remove("border-red-500");
}
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (saveBtn) saveBtn.classList.remove("hidden");
if (confirmBtn) confirmBtn.classList.add("hidden");
// Refresh the preset list
loadPresets();
})
.catch(function (err) {
console.error("Failed to save preset:", err);
});
};
/** Extract CSRF token from the page. */
function getCsrfToken() {
var cookie = document.cookie
.split("; ")
.find(function (row) {
return row.startsWith("csrftoken=");
});
if (cookie) return cookie.split("=")[1];
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
return el ? el.value : "";
}
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
}
injectSearchInputs();
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
loadPresets();
});
})();
-96
View File
@@ -1,96 +0,0 @@
/**
* Dual-handle range slider pure JS with draggable handles.
*/
(function () {
"use strict";
function initAll(force) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
var track = slider.querySelector(".range-track-fill");
if (!minHandle || !maxHandle) return;
var minTarget = document.getElementById(minHandle.getAttribute("data-target"));
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
var dMin = parseInt(slider.getAttribute("data-min"), 10);
var dMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
function percentToValue(p) {
var raw = dMin + (p / 100) * (dMax - dMin);
return Math.round(raw / step) * step;
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
function setTargetVal(el, v) { if (el) el.value = v; }
function update() {
var minV = getTargetVal(minTarget);
var maxV = getTargetVal(maxTarget);
minV = clamp(minV, dMin, dMax);
maxV = clamp(maxV, dMin, dMax);
if (minV > maxV) minV = maxV;
if (maxV < minV) maxV = minV;
setTargetVal(minTarget, minV);
setTargetVal(maxTarget, maxV);
var minP = valueToPercent(minV);
var maxP = valueToPercent(maxV);
minHandle.style.left = minP + "%";
maxHandle.style.left = maxP + "%";
if (track) {
track.style.left = minP + "%";
track.style.width = (maxP - minP) + "%";
}
}
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var v = percentToValue(clamp(pct, 0, 100));
if (isMin) {
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
} else {
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
}
update();
// Trigger input event on the target so any listeners fire
var tgt = isMin ? minTarget : maxTarget;
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// Sync from inputs to slider
function fromInputs() { update(); }
if (minTarget) minTarget.addEventListener("input", fromInputs);
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
update();
});
}
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
// Expose for manual re-init (filter bar toggle)
window.initRangeSliders = initAll;
})();
-149
View File
@@ -1,149 +0,0 @@
/**
* SelectableFilter widget Stash-style choice filter with search,
* include/exclude buttons, and modifier tags (Any / None).
*/
(function () {
"use strict";
function initAll() {
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
if (el._sfInit) return;
el._sfInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector(".sf-search");
var options = container.querySelector(".sf-options");
var selectedArea = container.querySelector(".sf-selected");
if (!search || !options || !selectedArea) return;
// ── Search ──
search.addEventListener("input", function () {
var q = search.value.toLowerCase();
options.querySelectorAll(".sf-option").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
});
});
// ── Include / Exclude clicks ──
options.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (btn) {
var action = btn.getAttribute("data-action");
var itemEl = btn.closest(".sf-option");
if (!itemEl) return;
var value = itemEl.getAttribute("data-value");
var label = itemEl.getAttribute("data-label");
if (!value) return;
if (action === "include") addTag(container, value, label, "include");
else if (action === "exclude") addTag(container, value, label, "exclude");
return;
}
// Click on modifier option (not a button)
var modOption = e.target.closest(".sf-modifier-option");
if (modOption) {
var modVal = modOption.getAttribute("data-modifier");
setModifier(container, modVal);
}
});
// ── Remove selected tag ──
selectedArea.addEventListener("click", function (e) {
var removeBtn = e.target.closest(".sf-remove");
if (removeBtn) {
removeBtn.closest(".sf-tag").remove();
return;
}
// Click on active modifier tag → deselect it
var modTag = e.target.closest(".sf-modifier-tag");
if (modTag) {
clearModifier(container);
}
});
}
/** Add a tag to the selected area and clear modifier. */
function addTag(container, value, label, type) {
clearModifier(container);
var selectedArea = container.querySelector(".sf-selected");
// Check if already present
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
if (existing) {
if (existing.getAttribute("data-type") !== type) {
existing.setAttribute("data-type", type);
existing.classList.toggle("sf-excluded", type === "exclude");
var text = existing.querySelector(".sf-tag-text");
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
}
return;
}
var tag = document.createElement("span");
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
tag.setAttribute("data-value", value);
tag.setAttribute("data-type", type);
tag.innerHTML =
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
selectedArea.appendChild(tag);
}
/** Set a modifier (Any / None) — clears all tags. */
function setModifier(container, modVal) {
var selectedArea = container.querySelector(".sf-selected");
// Clear all tags
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
// Clear existing modifier tag
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
// Add new modifier tag
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
var tag = document.createElement("span");
tag.className = "sf-modifier-tag active";
tag.setAttribute("data-modifier", modVal);
tag.textContent = label;
selectedArea.appendChild(tag);
container.setAttribute("data-modifier", modVal);
}
/** Clear any active modifier, removing the tag. */
function clearModifier(container) {
var selectedArea = container.querySelector(".sf-selected");
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
container.removeAttribute("data-modifier");
}
// Read selections for form submission
window.readSelectableFilters = function (form) {
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
var modifier = container.getAttribute("data-modifier");
var modTag = container.querySelector(".sf-modifier-tag.active");
if (modTag) modifier = modTag.getAttribute("data-modifier");
var included = [];
var excluded = [];
container.querySelectorAll(".sf-tag").forEach(function (tag) {
var val = tag.getAttribute("data-value");
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
else included.push(val);
});
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
+3 -13
View File
@@ -2,7 +2,6 @@ from django.urls import path
from games.views import ( from games.views import (
device, device,
filter_presets,
game, game,
general, general,
platform, platform,
@@ -161,18 +160,9 @@ urlpatterns = [
name="list_statuschanges", name="list_statuschanges",
), ),
path("stats/", general.stats_alltime, name="stats_alltime"), path("stats/", general.stats_alltime, name="stats_alltime"),
path("stats/<int:year>", general.stats, name="stats_by_year"),
# Filter presets
path("filter/presets/list", filter_presets.list_presets, name="list_presets"),
path("filter/presets/save", filter_presets.save_preset, name="save_preset"),
path( path(
"filter/presets/<int:preset_id>/delete", "stats/<int:year>",
filter_presets.delete_preset, general.stats,
name="delete_preset", name="stats_by_year",
),
path(
"filter/presets/<int:preset_id>/load",
filter_presets.load_preset,
name="load_preset",
), ),
] ]
+13 -3
View File
@@ -1,4 +1,5 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
@@ -13,15 +14,24 @@ from common.components import (
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
from common.utils import paginate
from games.forms import DeviceForm from games.forms import DeviceForm
from games.models import Device from games.models import Device
@login_required @login_required
def list_devices(request: HttpRequest) -> HttpResponse: def list_devices(request: HttpRequest) -> HttpResponse:
devices, page_obj, elided_page_range = paginate( page_number = request.GET.get("page", 1)
request, Device.objects.order_by("-created_at") limit = request.GET.get("limit", 10)
devices = Device.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(devices, limit)
page_obj = paginator.get_page(page_number)
devices = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
) )
data = { data = {
-100
View File
@@ -1,100 +0,0 @@
"""Views for managing saved filter presets (FilterPreset model)."""
import json
from urllib.parse import quote
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from games.models import FilterPreset
@login_required
def list_presets(request: HttpRequest) -> HttpResponse:
"""Return a preset dropdown as an HTML fragment."""
mode = request.GET.get("mode", "games")
presets = FilterPreset.objects.filter(mode=mode).order_by("name")
items: list[str] = []
for preset in presets:
filter_json = (
json.dumps(preset.object_filter) if preset.object_filter else ""
)
list_url = reverse(f"games:list_{mode}")
delete_url = reverse("games:delete_preset", args=[preset.id])
items.append(
f"<li>"
f'<a href="{list_url}?filter={quote(filter_json)}" '
f'class="flex justify-between items-center px-4 py-2 text-sm '
f'text-heading hover:bg-neutral-secondary-medium">'
f"<span>{preset.name}</span>"
f'<span class="text-red-500 hover:text-red-700 cursor-pointer ml-4" '
f'data-delete-preset="{preset.id}" '
f'href="{delete_url}">x</span>'
f"</a></li>"
)
if not items:
items = [
'<li class="px-4 py-2 text-sm text-body italic">'
"No saved presets</li>"
]
return HttpResponse(
mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')
)
@login_required
def save_preset(request: HttpRequest) -> HttpResponse:
"""Save the current filter as a new preset."""
if request.method != "POST":
return HttpResponse(status=405)
name = request.POST.get("name", "").strip()
mode = request.POST.get("mode", "games")
filter_json_str = request.POST.get("filter", "")
if not name:
messages.error(request, "Preset name is required.")
return HttpResponse(status=400)
object_filter: dict = {}
if filter_json_str:
try:
object_filter = json.loads(filter_json_str)
except json.JSONDecodeError:
pass
FilterPreset.objects.create(
name=name,
mode=mode,
object_filter=object_filter,
)
messages.success(request, f'Filter preset "{name}" saved.')
return HttpResponse(status=201)
@login_required
def delete_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
"""Delete a saved filter preset."""
preset = get_object_or_404(FilterPreset, id=preset_id)
name = preset.name
preset.delete()
messages.success(request, f'Preset "{name}" deleted.')
return HttpResponse(status=200)
@login_required
def load_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
"""Load a preset and redirect to the appropriate list view."""
preset = get_object_or_404(FilterPreset, id=preset_id)
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
return redirect(
f"{reverse(f'games:list_{preset.mode}')}?filter={quote(filter_json)}"
)
+34 -45
View File
@@ -18,7 +18,6 @@ from common.components import (
Component, Component,
CsrfInput, CsrfInput,
Div, Div,
FilterBar,
GameStatus, GameStatus,
GameStatusSelector, GameStatusSelector,
H1, H1,
@@ -42,8 +41,7 @@ from common.time import (
local_strftime, local_strftime,
timeformat, timeformat,
) )
from common.utils import build_dynamic_filter, paginate, safe_division, truncate from common.utils import build_dynamic_filter, safe_division, truncate
from games.filters import parse_game_filter
from games.forms import GameForm from games.forms import GameForm
from games.models import Game from games.models import Game
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@@ -52,37 +50,40 @@ from games.views.playevent import create_playevent_tabledata
@login_required @login_required
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse: def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
games = Game.objects.order_by("-created_at") games = Game.objects.order_by("-created_at")
page_obj = None
search_string = request.GET.get("search_string", search_string)
if search_string != "":
filters = [
Q(name__icontains=search_string),
Q(sort_name__icontains=search_string),
Q(platform__name__icontains=search_string),
]
try:
year_value = int(search_string)
except ValueError:
year_value = None
if year_value:
filters.append(Q(year_released=year_value))
search_string_parts = search_string.split()
# only search for status if it exactly matches and is the only word
if len(search_string_parts) == 1:
if search_string.title() in Game.Status.labels:
search_status = Game.Status[search_string.upper()]
filters.append(Q(status=search_status))
games = games.filter(build_dynamic_filter(filters, "|"))
if int(limit) != 0:
paginator = Paginator(games, limit)
page_obj = paginator.get_page(page_number)
games = page_obj.object_list
# ── Structured filter (Stash-style JSON) ── elided_page_range = (
filter_json = request.GET.get("filter", "") page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if filter_json: if page_obj
game_filter = parse_game_filter(filter_json) else None
if game_filter is not None: )
games = games.filter(game_filter.to_q())
else:
# ── Legacy free-text search ──
search_string = request.GET.get("search_string", search_string)
if search_string != "":
filters = [
Q(name__icontains=search_string),
Q(sort_name__icontains=search_string),
Q(platform__name__icontains=search_string),
]
try:
year_value = int(search_string)
except ValueError:
year_value = None
if year_value:
filters.append(Q(year_released=year_value))
search_string_parts = search_string.split()
if len(search_string_parts) == 1:
if search_string.title() in Game.Status.labels:
search_status = Game.Status[search_string.upper()]
filters.append(Q(status=search_status))
games = games.filter(build_dynamic_filter(filters, "|"))
games, page_obj, elided_page_range = paginate(request, games)
data = { data = {
"header_action": Div( "header_action": Div(
@@ -137,19 +138,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
# Prepend the filter bar above the table return render_page(request, content, title="Manage games")
filter_bar = FilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage games",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
)
@login_required @login_required
+9 -15
View File
@@ -109,8 +109,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter( this_year_purchases_without_refunded.filter(
~Q(games__status=Game.Status.FINISHED) ~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
& ~Q(games__playevents__ended__isnull=False)
) )
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
@@ -118,16 +117,14 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
this_year_purchases_unfinished = ( this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status=Game.Status.RETIRED) ~Q(games__status="r") & ~Q(games__status="a")
& ~Q(games__status=Game.Status.ABANDONED)
) )
) )
this_year_purchases_dropped = ( this_year_purchases_dropped = (
this_year_purchases.filter( this_year_purchases.filter(
~Q(games__status=Game.Status.FINISHED) ~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
& ~Q(games__playevents__ended__isnull=False)
) )
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False)) .filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) )
@@ -341,8 +338,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
# only Game and DLC # only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter( this_year_purchases_without_refunded.filter(
~Q(games__status=Game.Status.FINISHED) ~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
& ~Q(games__playevents__ended__year=year)
) )
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
@@ -351,17 +347,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
# unfinished = not finished AND not dropped # unfinished = not finished AND not dropped
this_year_purchases_unfinished = ( this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status=Game.Status.RETIRED) ~Q(games__status="r") & ~Q(games__status="a")
& ~Q(games__status=Game.Status.ABANDONED)
) )
) )
# dropped = abandoned OR retired OR refunded (OR logic for transition) # dropped = abandoned OR retired OR refunded (OR logic for transition)
this_year_purchases_dropped = ( this_year_purchases_dropped = (
this_year_purchases.filter( this_year_purchases.filter(
~Q(games__status=Game.Status.FINISHED) ~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
& ~Q(games__playevents__ended__year=year)
) )
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False)) .filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) )
@@ -438,7 +432,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
backlog_decrease_count = ( backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year) Purchase.objects.filter(date_purchased__year__lt=year)
.filter(games__status=Game.Status.FINISHED) .filter(games__status="f")
.filter(games__playevents__ended__year=year) .filter(games__playevents__ended__year=year)
.count() .count()
) )
+13 -3
View File
@@ -1,4 +1,5 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
@@ -13,7 +14,6 @@ from common.components import (
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
from common.utils import paginate
from games.forms import PlatformForm from games.forms import PlatformForm
from games.models import Platform from games.models import Platform
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@@ -21,8 +21,18 @@ from games.views.general import use_custom_redirect
@login_required @login_required
def list_platforms(request: HttpRequest) -> HttpResponse: def list_platforms(request: HttpRequest) -> HttpResponse:
platforms, page_obj, elided_page_range = paginate( page_number = request.GET.get("page", 1)
request, Platform.objects.order_by("name") limit = request.GET.get("limit", 10)
platforms = Platform.objects.order_by("name")
page_obj = None
if int(limit) != 0:
paginator = Paginator(platforms, limit)
page_obj = paginator.get_page(page_number)
platforms = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
) )
data = { data = {
+13 -3
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from typing import Any, Callable, TypedDict from typing import Any, Callable, TypedDict
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import QuerySet from django.db.models import QuerySet
from django.db.models.manager import BaseManager from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
@@ -19,7 +20,6 @@ from common.components import (
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime from common.time import dateformat, format_duration, local_strftime
from common.utils import paginate
from games.forms import PlayEventForm from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session from games.models import Game, PlayEvent, Session
@@ -125,8 +125,18 @@ def _get_formatted_playtime_for_game_sessions_in_range(
@login_required @login_required
def list_playevents(request: HttpRequest) -> HttpResponse: def list_playevents(request: HttpRequest) -> HttpResponse:
playevents, page_obj, elided_page_range = paginate( page_number = request.GET.get("page", 1)
request, PlayEvent.objects.order_by("-created_at") limit = request.GET.get("limit", 10)
playevents = PlayEvent.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(playevents, limit)
page_obj = paginator.get_page(page_number)
playevents = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
) )
data = create_playevent_tabledata(playevents, request=request) data = create_playevent_tabledata(playevents, request=request)
content = paginated_table_content( content = paginated_table_content(
+15 -24
View File
@@ -1,5 +1,6 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import ( from django.http import (
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
@@ -12,7 +13,7 @@ from django.views.decorators.http import require_POST
from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText
from common.components import ( from common.components import (
A, A,
@@ -34,7 +35,6 @@ from common.components import (
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat from common.time import dateformat
from common.utils import paginate
from games.forms import PurchaseForm from games.forms import PurchaseForm
from games.models import Game, Purchase from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@@ -95,16 +95,19 @@ def _render_purchase_row(purchase):
@login_required @login_required
def list_purchases(request: HttpRequest) -> HttpResponse: def list_purchases(request: HttpRequest) -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
purchases = Purchase.objects.order_by("-date_purchased", "-created_at") purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
page_obj = None
filter_json = request.GET.get("filter", "") if int(limit) != 0:
if filter_json: paginator = Paginator(purchases, limit)
from games.filters import parse_purchase_filter page_obj = paginator.get_page(page_number)
pf = parse_purchase_filter(filter_json) purchases = page_obj.object_list
if pf is not None: elided_page_range = (
purchases = purchases.filter(pf.to_q()) page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
purchases, page_obj, elided_page_range = paginate(request, purchases) else None
)
data = { data = {
"header_action": A( "header_action": A(
@@ -128,19 +131,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
from common.components import PurchaseFilterBar, ModuleScript return render_page(request, content, title="Manage purchases")
filter_bar = PurchaseFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage purchases",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
)
def _purchase_additional_row() -> SafeText: def _purchase_additional_row() -> SafeText:
+25 -35
View File
@@ -1,6 +1,7 @@
from typing import Any from typing import Any
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
@@ -31,39 +32,41 @@ from common.time import (
local_strftime, local_strftime,
timeformat, timeformat,
) )
from common.utils import paginate, truncate from common.utils import truncate
from games.forms import SessionForm from games.forms import SessionForm
from games.models import Device, Game, Session from games.models import Device, Game, Session
@login_required @login_required
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse: def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start", "created_at") sessions = Session.objects.order_by("-timestamp_start", "created_at")
device_list = Device.objects.order_by("name") device_list = Device.objects.order_by("name")
search_string = request.GET.get("search_string", search_string)
# ── Structured filter (JSON) ── if search_string != "":
filter_json = request.GET.get("filter", "") sessions = sessions.filter(
if filter_json: Q(game__name__icontains=search_string)
from games.filters import parse_session_filter | Q(game__name__icontains=search_string)
session_filter = parse_session_filter(filter_json) | Q(game__platform__name__icontains=search_string)
if session_filter is not None: | Q(device__name__icontains=search_string)
sessions = sessions.filter(session_filter.to_q()) | Q(device__type__icontains=search_string)
else: )
# ── Legacy free-text search ──
search_string = request.GET.get("search_string", search_string)
if search_string != "":
sessions = sessions.filter(
Q(game__name__icontains=search_string)
| Q(game__name__icontains=search_string)
| Q(game__platform__name__icontains=search_string)
| Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string)
)
try: try:
last_session = sessions.latest() last_session = sessions.latest()
except Session.DoesNotExist: except Session.DoesNotExist:
last_session = None last_session = None
sessions, page_obj, elided_page_range = paginate(request, sessions) page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
page_obj = paginator.get_page(page_number)
sessions = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
data = { data = {
"header_action": Div( "header_action": Div(
@@ -167,20 +170,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
from common.components import SessionFilterBar return render_page(request, content, title="Manage sessions")
filter_json = request.GET.get("filter", "")
filter_bar = SessionFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage sessions",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
)
@login_required @login_required
+15 -5
View File
@@ -1,4 +1,5 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
@@ -15,7 +16,6 @@ from common.components import (
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
from common.utils import paginate
from games.forms import GameStatusChangeForm from games.forms import GameStatusChangeForm
from games.models import GameStatusChange from games.models import GameStatusChange
@@ -36,8 +36,8 @@ def edit_statuschange(request: HttpRequest, statuschange_id: int) -> HttpRespons
statuschange = get_object_or_404(GameStatusChange, id=statuschange_id) statuschange = get_object_or_404(GameStatusChange, id=statuschange_id)
form = GameStatusChangeForm(request.POST or None, instance=statuschange) form = GameStatusChangeForm(request.POST or None, instance=statuschange)
if form.is_valid(): if form.is_valid():
saved = form.save() form.save()
return redirect("games:view_game", game_id=saved.game.id) return redirect("games:list_platforms")
return render_page( return render_page(
request, AddForm(form, request=request), title="Edit status change" request, AddForm(form, request=request), title="Edit status change"
) )
@@ -45,8 +45,18 @@ def edit_statuschange(request: HttpRequest, statuschange_id: int) -> HttpRespons
@login_required @login_required
def list_statuschanges(request: HttpRequest) -> HttpResponse: def list_statuschanges(request: HttpRequest) -> HttpResponse:
statuschanges, page_obj, elided_page_range = paginate( page_number = request.GET.get("page", 1)
request, GameStatusChange.objects.select_related("game").all() limit = request.GET.get("limit", 10)
statuschanges = GameStatusChange.objects.select_related("game").all()
page_obj = None
if int(limit) != 0:
paginator = Paginator(statuschanges, limit)
page_obj = paginator.get_page(page_number)
statuschanges = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
) )
data = { data = {
+252 -87
View File
@@ -3,15 +3,139 @@ from unittest.mock import MagicMock, patch
import django import django
from django.template import TemplateDoesNotExist
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common import components from common import components
from games.models import Platform, Game, Purchase, Session from games.models import Platform, Game, Purchase, Session
class RenderCachedImplTest(unittest.TestCase):
"""Test _render_cached_impl renders templates correctly."""
def test_basic_render(self):
result = components._render_cached_impl(
"icons/play.html",
'{"slot": "", "title": "Play"}',
)
self.assertIn("<svg", result)
self.assertIn("</svg>", result)
def test_slot_marked_safe(self):
result = components._render_cached_impl(
"icons/play.html",
'{"slot": "<b>bold</b>", "title": "Play"}',
)
self.assertIsInstance(result, SafeText)
def test_different_templates_different_output(self):
r1 = components._render_cached_impl(
"icons/play.html", '{"slot": "", "title": "Play"}',
)
r2 = components._render_cached_impl(
"icons/delete.html", '{"slot": "", "title": "Delete"}',
)
self.assertNotEqual(r1, r2)
def test_nonexistent_template_raises(self):
with self.assertRaises(TemplateDoesNotExist):
components._render_cached_impl(
"nonexistent.html", '{"slot": "", "title": "X"}',
)
def test_context_keys_are_sorted(self):
"""Verify sort_keys=True in Component produces consistent JSON."""
from common.components import Component
r1 = Component(
template="icons/play.html",
attributes=[("title", "Play"), ("b", "2")],
)
r2 = Component(
template="icons/play.html",
attributes=[("b", "2"), ("title", "Play")],
)
self.assertEqual(r1, r2)
class RenderCachedLRUTest(unittest.TestCase):
"""Test LRU cache behavior of _render_cached when enabled."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_cache_hits_and_misses(self):
# Call through _render_cached (the cached wrapper), not _render_cached_impl
components._render_cached(
"icons/play.html", '{"slot": "", "title": "Play"}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.hits, 0)
self.assertEqual(info.misses, 1)
components._render_cached(
"icons/play.html", '{"slot": "", "title": "Play"}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.hits, 1)
self.assertEqual(info.misses, 1)
def test_cache_clear(self):
components._render_cached_impl(
"icons/play.html", '{"slot": "", "title": "Play"}',
)
components._render_cached.cache_clear()
info = components._render_cached.cache_info()
self.assertEqual(info.currsize, 0)
self.assertEqual(info.hits, 0)
def test_cache_parameters(self):
self.assertEqual(components._render_cached.cache_parameters()["maxsize"], 4096)
def test_different_contexts_different_entries(self):
# Call through _render_cached (the cached wrapper), not _render_cached_impl
components._render_cached(
"icons/play.html",
'{"slot": "", "title": "Play"}',
)
components._render_cached(
"icons/play.html",
'{"slot": "", "title": "Pause"}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.currsize, 2)
def test_cache_size_limited(self):
"""After exceeding maxsize, oldest entries are evicted."""
for i in range(5000):
components._render_cached_impl(
"icons/play.html",
f'{{"slot": "", "title": "{i}"}}',
)
info = components._render_cached.cache_info()
self.assertLessEqual(info.currsize, 4096)
class ComponentIntegrationTest(unittest.TestCase): class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent.""" """Test Component() works correctly with caching transparent."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_template_component(self):
result = components.Component(
template="icons/play.html", attributes=[],
)
self.assertIn("<svg", result)
self.assertIn("</svg>", result)
def test_tag_name_component(self): def test_tag_name_component(self):
result = components.Component( result = components.Component(
tag_name="div", tag_name="div",
@@ -20,34 +144,23 @@ class ComponentIntegrationTest(unittest.TestCase):
) )
self.assertEqual(result, '<div class="test">hello</div>') self.assertEqual(result, '<div class="test">hello</div>')
def test_repeated_calls_identical(self):
class ComponentCacheTest(unittest.TestCase): r1 = components.Component(
"""Component rendering is memoized via _render_element.""" template="icons/play.html", attributes=[],
def setUp(self):
components._render_element.cache_clear()
def test_identical_components_hit_cache(self):
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
misses = components._render_element.cache_info().misses
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
info = components._render_element.cache_info()
self.assertEqual(info.misses, misses) # no new miss
self.assertGreaterEqual(info.hits, 1) # served from cache
def test_cache_is_bounded(self):
self.assertEqual(
components._render_element.cache_parameters()["maxsize"], 4096
) )
r2 = components.Component(
template="icons/play.html", attributes=[],
)
self.assertEqual(r1, r2)
def test_safe_and_unsafe_children_do_not_collide(self): def test_different_components_different(self):
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must r1 = components.Component(
render differently the cache key must keep them distinct.""" template="icons/play.html", attributes=[],
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")]) )
unsafe = components.Component(tag_name="span", children=["<b>x</b>"]) r2 = components.Component(
self.assertIn("<b>x</b>", safe) template="icons/delete.html", attributes=[],
self.assertIn("&lt;b&gt;x&lt;/b&gt;", unsafe) )
self.assertNotEqual(safe, unsafe) self.assertNotEqual(r1, r2)
class RandomidDeterministicTest(unittest.TestCase): class RandomidDeterministicTest(unittest.TestCase):
@@ -76,9 +189,7 @@ class RandomidDeterministicTest(unittest.TestCase):
def test_output_is_lowercase_alphanum(self): def test_output_is_lowercase_alphanum(self):
result = components.randomid(content="test") result = components.randomid(content="test")
self.assertTrue( self.assertTrue(all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in result))
all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in result)
)
def test_output_length_is_correct(self): def test_output_length_is_correct(self):
for length in [5, 10, 15, 20]: for length in [5, 10, 15, 20]:
@@ -96,7 +207,6 @@ class RandomidVsOldBehaviorTest(unittest.TestCase):
def _old_random_id(self, seed="", length=10): def _old_random_id(self, seed="", length=10):
from random import choices from random import choices
from string import ascii_lowercase from string import ascii_lowercase
return seed + "".join(choices(ascii_lowercase, k=length)) return seed + "".join(choices(ascii_lowercase, k=length))
def test_old_random_produces_different_ids(self): def test_old_random_produces_different_ids(self):
@@ -115,6 +225,13 @@ class RandomidVsOldBehaviorTest(unittest.TestCase):
class PopoverDeterministicTest(unittest.TestCase): class PopoverDeterministicTest(unittest.TestCase):
"""Test that Popover() produces deterministic HTML output.""" """Test that Popover() produces deterministic HTML output."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_same_popover_same_id(self): def test_same_popover_same_id(self):
r1 = components.Popover("hello", wrapped_content="hello") r1 = components.Popover("hello", wrapped_content="hello")
r2 = components.Popover("hello", wrapped_content="hello") r2 = components.Popover("hello", wrapped_content="hello")
@@ -151,28 +268,24 @@ class TemplatetagRandomidTest(unittest.TestCase):
def test_same_seed_same_id(self): def test_same_seed_same_id(self):
from games.templatetags import randomid from games.templatetags import randomid
r1 = randomid.randomid(seed="foo") r1 = randomid.randomid(seed="foo")
r2 = randomid.randomid(seed="foo") r2 = randomid.randomid(seed="foo")
self.assertEqual(r1, r2) self.assertEqual(r1, r2)
def test_different_seed_different_id(self): def test_different_seed_different_id(self):
from games.templatetags import randomid from games.templatetags import randomid
r1 = randomid.randomid(seed="foo") r1 = randomid.randomid(seed="foo")
r2 = randomid.randomid(seed="bar") r2 = randomid.randomid(seed="bar")
self.assertNotEqual(r1, r2) self.assertNotEqual(r1, r2)
def test_output_length_ten(self): def test_output_length_ten(self):
from games.templatetags import randomid from games.templatetags import randomid
for seed in ["a", "hello", "test1234"]: for seed in ["a", "hello", "test1234"]:
result = randomid.randomid(seed=seed) result = randomid.randomid(seed=seed)
self.assertEqual(len(result), 10) self.assertEqual(len(result), 10)
def test_empty_seed_returns_hash(self): def test_empty_seed_returns_hash(self):
from games.templatetags import randomid from games.templatetags import randomid
result = randomid.randomid() result = randomid.randomid()
self.assertEqual(len(result), 10) self.assertEqual(len(result), 10)
self.assertTrue(all(c in "abcdef0123456789" for c in result)) self.assertTrue(all(c in "abcdef0123456789" for c in result))
@@ -181,6 +294,13 @@ class TemplatetagRandomidTest(unittest.TestCase):
class ComponentReturnTypeTest(unittest.TestCase): class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly.""" """Test that component functions return SafeText and render correctly."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_div_returns_safe_text(self): def test_div_returns_safe_text(self):
result = components.Div([("class", "x")], "hello") result = components.Div([("class", "x")], "hello")
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
@@ -194,7 +314,7 @@ class ComponentReturnTypeTest(unittest.TestCase):
def test_div_no_args(self): def test_div_no_args(self):
result = components.Div(children="test") result = components.Div(children="test")
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("<div>test</div>", result) self.assertIn('<div>test</div>', result)
def test_a_returns_safe_text(self): def test_a_returns_safe_text(self):
result = components.A([], "link") result = components.A([], "link")
@@ -206,15 +326,14 @@ class ComponentReturnTypeTest(unittest.TestCase):
def test_a_url_name_reversed(self): def test_a_url_name_reversed(self):
from unittest.mock import patch from unittest.mock import patch
with patch("common.components.reverse", return_value="/resolved/url"): with patch("common.components.reverse", return_value="/resolved/url"):
result = components.A([], "link", url_name="some_name") result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result) self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self): def test_a_no_url_or_href(self):
result = components.A([], "link") result = components.A([], "link")
self.assertIn("<a>link</a>", result) self.assertIn('<a>link</a>', result)
self.assertNotIn("href=", result) self.assertNotIn('href=', result)
def test_a_both_url_name_and_href_raises(self): def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -249,14 +368,12 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
("A", components.A(href="/foo", children=["link"])), ("A", components.A(href="/foo", children=["link"])),
("Button", components.Button([], "click")), ("Button", components.Button([], "click")),
("Div", components.Div([], ["hello"])), ("Div", components.Div([], ["hello"])),
("Form", components.Form(children=["x"])),
("Input", components.Input()), ("Input", components.Input()),
("ButtonGroup", components.ButtonGroup([])), ("ButtonGroup", components.ButtonGroup([])),
( ("ButtonGroup with buttons", components.ButtonGroup(
"ButtonGroup with buttons", [{"href": "/", "slot": components.Icon("edit")}]
components.ButtonGroup( )),
[{"href": "/", "slot": components.Icon("edit")}]
),
),
("SearchField", components.SearchField()), ("SearchField", components.SearchField()),
("PriceConverted", components.PriceConverted(["27 CZK"])), ("PriceConverted", components.PriceConverted(["27 CZK"])),
("H1", components.H1(["Title"])), ("H1", components.H1(["Title"])),
@@ -270,8 +387,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_button_with_icon_children_not_escaped(self): def test_button_with_icon_children_not_escaped(self):
result = components.Button( result = components.Button(
icon=True, icon=True, size="xs",
size="xs",
children=[components.Icon("play"), "LOG"], children=[components.Icon("play"), "LOG"],
) )
self.assertTrue(str(result).startswith("<button")) self.assertTrue(str(result).startswith("<button"))
@@ -281,9 +397,7 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
popover_content="test tooltip", popover_content="test tooltip",
children=[ children=[
components.Button( components.Button(
icon=True, icon=True, color="gray", size="xs",
color="gray",
size="xs",
children=[components.Icon("play"), "test"], children=[components.Icon("play"), "test"],
), ),
], ],
@@ -298,17 +412,19 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
class ComponentEdgeCasesTest(unittest.TestCase): class ComponentEdgeCasesTest(unittest.TestCase):
"""Test Component() edge cases and error handling.""" """Test Component() edge cases and error handling."""
def test_no_tag_name_raises(self): def test_no_template_or_tag_name_raises(self):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
components.Component(children="hello") components.Component(children="hello")
self.assertIn("tag_name", str(ctx.exception)) self.assertIn("template or tag_name", str(ctx.exception))
def test_single_string_children_wrapped(self): def test_single_string_children_wrapped(self):
result = components.Component(tag_name="span", children="hello") result = components.Component(tag_name="span", children="hello")
self.assertIn("hello", result) self.assertIn("hello", result)
def test_multiple_children_joined_with_newlines(self): def test_multiple_children_joined_with_newlines(self):
result = components.Component(tag_name="div", children=["hello", "world"]) result = components.Component(
tag_name="div", children=["hello", "world"]
)
self.assertIn("hello\nworld", result) self.assertIn("hello\nworld", result)
self.assertIn("<div>", result) self.assertIn("<div>", result)
self.assertIn("</div>", result) self.assertIn("</div>", result)
@@ -359,6 +475,13 @@ class ComponentEdgeCasesTest(unittest.TestCase):
class IconTest(unittest.TestCase): class IconTest(unittest.TestCase):
"""Test Icon() component function.""" """Test Icon() component function."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_valid_icon_renders_svg(self): def test_valid_icon_renders_svg(self):
result = components.Icon("play") result = components.Icon("play")
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
@@ -379,12 +502,33 @@ class IconTest(unittest.TestCase):
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
class InputTest(unittest.TestCase): class FormInputTest(unittest.TestCase):
"""Test the Input() component.""" """Test Form(), Input(), and Div() functions."""
def test_form_default_method_get(self):
result = components.Form()
self.assertIn('method="get"', result)
self.assertIn('<form', result)
def test_form_post_method(self):
result = components.Form(method="post")
self.assertIn('method="post"', result)
self.assertIn('<form', result)
def test_form_action(self):
result = components.Form(action="/submit/")
self.assertIn('action="/submit/"', result)
def test_form_children_rendered(self):
child = components.Input(type="text", attributes=[("name", "email")])
result = components.Form(children=[child])
self.assertIn('<input', result)
self.assertIn('type="text"', result)
self.assertIn('name="email"', result)
def test_input_default_type_text(self): def test_input_default_type_text(self):
result = components.Input() result = components.Input()
self.assertIn("<input", result) self.assertIn('<input', result)
self.assertIn('type="text"', result) self.assertIn('type="text"', result)
def test_input_custom_type(self): def test_input_custom_type(self):
@@ -392,9 +536,7 @@ class InputTest(unittest.TestCase):
self.assertIn('type="submit"', result) self.assertIn('type="submit"', result)
def test_input_attributes_merged_with_type(self): def test_input_attributes_merged_with_type(self):
result = components.Input( result = components.Input(type="email", attributes=[("id", "email"), ("class", "form-input")])
type="email", attributes=[("id", "email"), ("class", "form-input")]
)
self.assertIn('type="email"', result) self.assertIn('type="email"', result)
self.assertIn('id="email"', result) self.assertIn('id="email"', result)
self.assertIn('class="form-input"', result) self.assertIn('class="form-input"', result)
@@ -403,6 +545,13 @@ class InputTest(unittest.TestCase):
class PopoverTruncatedTest(unittest.TestCase): class PopoverTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated() component function.""" """Test PopoverTruncated() component function."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_short_string_no_popover(self): def test_short_string_no_popover(self):
result = components.PopoverTruncated("hi") result = components.PopoverTruncated("hi")
self.assertEqual(result, "hi") self.assertEqual(result, "hi")
@@ -463,6 +612,20 @@ class PopoverTruncatedTest(unittest.TestCase):
self.assertIn("data-popover-target", result) self.assertIn("data-popover-target", result)
class EnableCacheTest(unittest.TestCase):
"""Test enable_cache() function."""
def test_wraps_with_lru_cache(self):
components.enable_cache()
# Should have cache_info method
self.assertTrue(hasattr(components._render_cached, "cache_info"))
def test_cache_has_correct_maxsize(self):
components.enable_cache()
params = components._render_cached.cache_parameters()
self.assertEqual(params["maxsize"], 4096)
class ModelDependentComponentsTest(django.test.TestCase): class ModelDependentComponentsTest(django.test.TestCase):
"""Test components that depend on Django models.""" """Test components that depend on Django models."""
@@ -570,8 +733,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Game A") game1 = self._create_game(platform, name="Game A")
game2 = self._create_game(platform, name="Game B") game2 = self._create_game(platform, name="Game B")
purchase = self._create_purchase( purchase = self._create_purchase(
[game1, game2], [game1, game2], price=24.99,
price=24.99,
) )
purchase.name = "Bundle" purchase.name = "Bundle"
purchase.save() purchase.save()
@@ -593,6 +755,13 @@ class ModelDependentComponentsTest(django.test.TestCase):
class PurchaseTruncatedTest(unittest.TestCase): class PurchaseTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated with endpart edge cases.""" """Test PopoverTruncated with endpart edge cases."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_endpart_shorter_than_length(self): def test_endpart_shorter_than_length(self):
text = "a" * 50 text = "a" * 50
result = components.PopoverTruncated(text, length=10, endpart="x") result = components.PopoverTruncated(text, length=10, endpart="x")
@@ -620,7 +789,9 @@ class NameWithIconPlatformTest(django.test.TestCase):
cls.game = Game.objects.create(name="Zelda", platform=cls.platform) cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
def test_name_with_icon_shows_platform_icon(self): def test_name_with_icon_shows_platform_icon(self):
result = components.NameWithIcon(name="Zelda", game=self.game, linkify=True) result = components.NameWithIcon(
name="Zelda", game=self.game, linkify=True
)
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
self.assertIn("Zelda", result) self.assertIn("Zelda", result)
@@ -652,10 +823,8 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.mock_session.pk = 1 self.mock_session.pk = 1
def test_session_provides_game_and_emulated(self): def test_session_provides_game_and_emulated(self):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon( "", self.mock_game, self.mock_session, True
"", self.mock_game, self.mock_session, True
)
) )
self.assertEqual(name, "Test Game") self.assertEqual(name, "Test Game")
self.assertIs(platform, self.mock_platform) self.assertIs(platform, self.mock_platform)
@@ -667,18 +836,16 @@ class ResolveNameWithIconTest(unittest.TestCase):
override_game.platform = self.mock_platform override_game.platform = self.mock_platform
override_game.pk = 99 override_game.pk = 99
with patch("common.components.reverse", return_value="/game/99"): with patch("common.components.reverse", return_value="/game/99"):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon( "", override_game, self.mock_session, True
"", override_game, self.mock_session, True
)
) )
self.assertEqual(name, "Test Game") self.assertEqual(name, "Test Game")
self.assertIsNot(name, "Override") self.assertIsNot(name, "Override")
def test_game_only_provides_platform(self): def test_game_only_provides_platform(self):
with patch("common.components.reverse", return_value="/game/1"): with patch("common.components.reverse", return_value="/game/1"):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon("", self.mock_game, None, True) "", self.mock_game, None, True
) )
self.assertEqual(name, "Test Game") self.assertEqual(name, "Test Game")
self.assertIs(platform, self.mock_platform) self.assertIs(platform, self.mock_platform)
@@ -686,36 +853,36 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.assertEqual(link, "/game/1") self.assertEqual(link, "/game/1")
def test_custom_name_overrides_game_name(self): def test_custom_name_overrides_game_name(self):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon("Custom", self.mock_game, None, False) "Custom", self.mock_game, None, False
) )
self.assertEqual(name, "Custom") self.assertEqual(name, "Custom")
def test_empty_name_falls_back_to_game_name(self): def test_empty_name_falls_back_to_game_name(self):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon("", self.mock_game, None, False) "", self.mock_game, None, False
) )
self.assertEqual(name, "Test Game") self.assertEqual(name, "Test Game")
def test_no_game_no_session_returns_empty_name(self): def test_no_game_no_session_returns_empty_name(self):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon("", None, None, False) "", None, None, False
) )
self.assertEqual(name, "") self.assertEqual(name, "")
self.assertIsNone(platform) self.assertIsNone(platform)
self.assertFalse(create_link) self.assertFalse(create_link)
def test_linkify_false_no_link_created(self): def test_linkify_false_no_link_created(self):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon("", self.mock_game, None, False) "", self.mock_game, None, False
) )
self.assertFalse(create_link) self.assertFalse(create_link)
self.assertEqual(link, "") self.assertEqual(link, "")
def test_linkify_true_creates_link(self): def test_linkify_true_creates_link(self):
with patch("common.components.reverse", return_value="/game/42"): with patch("common.components.reverse", return_value="/game/42"):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon("", self.mock_game, None, True) "", self.mock_game, None, True
) )
self.assertTrue(create_link) self.assertTrue(create_link)
self.assertEqual(link, "/game/42") self.assertEqual(link, "/game/42")
@@ -725,16 +892,14 @@ class ResolveNameWithIconTest(unittest.TestCase):
emulated_session.game = self.mock_game emulated_session.game = self.mock_game
emulated_session.emulated = True emulated_session.emulated = True
emulated_session.pk = 1 emulated_session.pk = 1
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon( "", self.mock_game, emulated_session, False
"", self.mock_game, emulated_session, False
)
) )
self.assertTrue(emulated) self.assertTrue(emulated)
def test_game_emulated_default_false(self): def test_game_emulated_default_false(self):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = components._resolve_name_with_icon(
components._resolve_name_with_icon("", self.mock_game, None, False) "", self.mock_game, None, False
) )
self.assertFalse(emulated) self.assertFalse(emulated)
-280
View File
@@ -1,280 +0,0 @@
"""Tests for the filtering system."""
import json
import pytest
from django.db.models import Q
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
IntCriterion,
Modifier,
MultiCriterion,
StringCriterion,
)
from common.components import FilterBar, SelectableFilter
from games.filters import GameFilter, parse_game_filter
class TestStringCriterion:
def test_equals(self):
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
assert c.to_q("name") == Q(name="zelda")
def test_is_null(self):
c = StringCriterion(value="", modifier=Modifier.IS_NULL)
assert c.to_q("name") == Q(name__isnull=True)
class TestIntCriterion:
def test_between(self):
c = IntCriterion(value=2020, value2=2024, modifier=Modifier.BETWEEN)
assert c.to_q("year_released") == Q(year_released__gte=2020, year_released__lte=2024)
class TestBoolCriterion:
def test_equals_true(self):
c = BoolCriterion(value=True, modifier=Modifier.EQUALS)
assert c.to_q("mastered") == Q(mastered=True)
class TestChoiceCriterion:
def test_includes(self):
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES)
assert c.to_q("status") == Q(status__in=["f", "p"])
def test_excludes(self):
c = ChoiceCriterion(value=["a"], modifier=Modifier.EXCLUDES)
assert c.to_q("status") == ~Q(status__in=["a"])
def test_excludes_only_empty_value(self):
"""Excluding a single status with no includes — value=[], excludes=["f"]."""
c = ChoiceCriterion(value=[], excludes=["f"], modifier=Modifier.INCLUDES)
q = c.to_q("status")
assert q == ~Q(status__in=["f"])
def test_excludes_two(self):
"""Excluding two statuses with no includes."""
c = ChoiceCriterion(value=[], excludes=["f", "a"], modifier=Modifier.INCLUDES)
q = c.to_q("status")
assert q == ~Q(status__in=["f", "a"])
def test_include_and_exclude(self):
"""Include f, exclude a — both lists set."""
c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.INCLUDES)
q = c.to_q("status")
assert q == Q(status__in=["f"]) & ~Q(status__in=["a"])
def test_include_two_and_exclude_one(self):
c = ChoiceCriterion(value=["f", "p"], excludes=["a"], modifier=Modifier.INCLUDES)
q = c.to_q("status")
assert q == Q(status__in=["f", "p"]) & ~Q(status__in=["a"])
def test_is_null(self):
c = ChoiceCriterion(value=[], modifier=Modifier.IS_NULL)
assert c.to_q("status") == Q(status__isnull=True)
def test_not_null(self):
c = ChoiceCriterion(value=[], modifier=Modifier.NOT_NULL)
assert c.to_q("status") == Q(status__isnull=False)
def test_excludes_modifier(self):
"""EXCLUDES modifier with value set."""
c = ChoiceCriterion(value=["f"], modifier=Modifier.EXCLUDES)
assert c.to_q("status") == ~Q(status__in=["f"])
def test_excludes_modifier_empty_value(self):
"""EXCLUDES modifier with empty value — should produce empty Q."""
c = ChoiceCriterion(value=[], modifier=Modifier.EXCLUDES)
q = c.to_q("status")
assert q == Q()
def test_not_equals(self):
c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS)
assert c.to_q("status") == ~Q(status__in=["f"])
class TestChoiceCriterionAgainstDB:
"""Verify ChoiceCriterion produces correct DB results."""
@pytest.fixture(autouse=True)
def setup(self, django_db_blocker):
pass
def _seed_games(self):
"""Create test games with different statuses."""
from games.models import Game, Platform
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
statuses = ["u", "p", "f", "r", "a"]
for i, s in enumerate(statuses):
Game.objects.get_or_create(
name=f"Test Game {i}",
defaults={"platform": platform, "status": s},
)
def _count(self, c: ChoiceCriterion) -> int:
from games.models import Game
return Game.objects.filter(c.to_q("status")).count()
def _statuses(self, c: ChoiceCriterion) -> set[str]:
from games.models import Game
return set(Game.objects.filter(c.to_q("status")).values_list("status", flat=True))
@pytest.mark.django_db
def test_include_finished_includes_only_finished(self):
self._seed_games()
c = ChoiceCriterion(value=["f"], modifier=Modifier.INCLUDES)
assert self._statuses(c) == {"f"}
@pytest.mark.django_db
def test_exclude_finished_excludes_finished(self):
self._seed_games()
c = ChoiceCriterion(value=[], excludes=["f"], modifier=Modifier.INCLUDES)
assert "f" not in self._statuses(c)
assert len(self._statuses(c)) == 4 # u, p, r, a
@pytest.mark.django_db
def test_include_and_exclude(self):
"""Include Finished but exclude Abandoned."""
self._seed_games()
c = ChoiceCriterion(value=["f", "a"], excludes=["a"], modifier=Modifier.INCLUDES)
# Include f and a, but exclude a → only f
assert self._statuses(c) == {"f"}
@pytest.mark.django_db
def test_include_two(self):
"""Include Finished AND Played."""
self._seed_games()
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES)
assert self._statuses(c) == {"f", "p"}
@pytest.mark.django_db
def test_exclude_two(self):
"""Exclude Finished AND Abandoned."""
self._seed_games()
c = ChoiceCriterion(value=[], excludes=["f", "a"], modifier=Modifier.INCLUDES)
statuses = self._statuses(c)
assert "f" not in statuses
assert "a" not in statuses
assert statuses == {"u", "p", "r"}
@pytest.mark.django_db
def test_not_null_has_results(self):
self._seed_games()
c = ChoiceCriterion(value=[], modifier=Modifier.NOT_NULL)
assert self._count(c) == 5
@pytest.mark.django_db
def test_is_null_no_results(self):
"""IS_NULL on a non-null field returns zero."""
self._seed_games()
c = ChoiceCriterion(value=[], modifier=Modifier.IS_NULL)
assert self._count(c) == 0
class TestGameFilterFromJson:
def test_status_choice_criterion(self):
gf = GameFilter.from_json(
{"status": {"value": ["f", "p"], "modifier": "INCLUDES"}}
)
assert gf is not None
assert gf.status is not None
assert gf.status.value == ["f", "p"]
assert gf.status.modifier == Modifier.INCLUDES
def test_status_not_null(self):
gf = GameFilter.from_json({"status": {"modifier": "NOT_NULL"}})
assert gf is not None
assert gf.status is not None
assert gf.status.modifier == Modifier.NOT_NULL
def test_platform_choice_criterion(self):
gf = GameFilter.from_json(
{"platform": {"value": ["1", "3"], "modifier": "INCLUDES"}}
)
assert gf is not None
assert gf.platform is not None
assert gf.platform.value == ["1", "3"]
def test_round_trip(self):
data = {"status": {"value": ["f"], "modifier": "INCLUDES"}, "mastered": {"value": True, "modifier": "EQUALS"}}
gf = GameFilter.from_json(data)
json_out = gf.to_json()
gf2 = GameFilter.from_json(json_out)
assert gf2 is not None
assert gf2.status is not None
assert gf2.mastered is not None
class TestGameFilterToQ:
def test_status_choice_includes(self):
gf = GameFilter.from_json(
{"status": {"value": ["f", "p"], "modifier": "INCLUDES"}}
)
q = gf.to_q()
assert q == Q(status__in=["f", "p"])
def test_status_not_null(self):
gf = GameFilter.from_json({"status": {"modifier": "NOT_NULL"}})
q = gf.to_q()
assert q == Q(status__isnull=False)
class TestFilterBarRendering:
"""Tests for FilterBar with SelectableFilter widgets."""
def test_status_uses_selectable_filter(self):
html = str(FilterBar(platform_options=[]))
assert "data-selectable-filter" in html
def test_mastered_not_checked_by_default(self):
html = str(FilterBar(filter_json="", platform_options=[]))
assert 'checked="true"' not in html
def test_mastered_checked_when_filtered(self):
html = str(
FilterBar(
platform_options=[],
filter_json=json.dumps({"mastered": {"value": True, "modifier": "EQUALS"}}),
)
)
assert 'checked="true"' in html
def test_status_prefilled(self):
html = str(
FilterBar(
platform_options=[],
filter_json=json.dumps({"status": {"value": ["f"], "modifier": "INCLUDES"}}),
)
)
assert 'data-value="f"' in html
assert "Finished" in html
def test_no_hx_get(self):
html = str(FilterBar(platform_options=[]))
assert "hx-get" not in html
def test_platform_options_rendered(self):
html = str(FilterBar(platform_options=[(1, "Steam"), (2, "Switch")]))
assert "Steam" in html
assert "Switch" in html
def test_status_has_no_modifiers(self):
"""Non-nullable fields should not show (None) but MUST show (Any)."""
html = str(FilterBar(platform_options=[]))
status_start = html.find('data-selectable-filter="status"')
platform_start = html.find('data-selectable-filter="platform"')
status_section = html[status_start:platform_start]
# Must have (Any) — always available
assert "(Any)" in status_section
# Must NOT have (None) — field is non-nullable
assert "(None)" not in status_section
def test_platform_has_modifiers(self):
"""Nullable ForeignKey fields should show (Any)/(None)."""
html = str(FilterBar(platform_options=[(1, "Steam")]))
platform_start = html.find('data-selectable-filter="platform"')
platform_section = html[platform_start:]
# Should have at least one modifier option
assert "(Any)" in platform_section or "(None)" in platform_section