Add filters
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
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")
|
||||||
+518
-1
@@ -7,6 +7,7 @@ from django.template.defaultfilters import floatformat
|
|||||||
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, 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
|
||||||
@@ -951,7 +952,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 game_count == 1 else "unspecified"
|
icon = (purchase.platform.icon if purchase.platform else "unspecified") 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(
|
||||||
@@ -1171,3 +1172,519 @@ 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..."])]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""
|
||||||
|
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())
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
+2
-1
@@ -43,7 +43,7 @@ class SessionForm(forms.ModelForm):
|
|||||||
),
|
),
|
||||||
label="Manual duration",
|
label="Manual duration",
|
||||||
)
|
)
|
||||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False)
|
||||||
|
|
||||||
mark_as_played = forms.BooleanField(
|
mark_as_played = forms.BooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
@@ -104,6 +104,7 @@ 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"),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -478,3 +478,33 @@ 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()})"
|
||||||
|
|||||||
+161
-2
@@ -826,6 +826,9 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -1273,6 +1276,9 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -1431,6 +1437,9 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -1461,9 +1470,15 @@
|
|||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -1656,6 +1671,10 @@
|
|||||||
--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);
|
||||||
@@ -1694,6 +1713,12 @@
|
|||||||
.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));
|
||||||
}
|
}
|
||||||
@@ -1826,6 +1851,9 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -1888,6 +1916,10 @@
|
|||||||
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;
|
||||||
@@ -1984,9 +2016,15 @@
|
|||||||
.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;
|
||||||
@@ -2090,6 +2128,12 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -2287,6 +2331,9 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -2446,6 +2493,10 @@
|
|||||||
--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;
|
||||||
}
|
}
|
||||||
@@ -2500,6 +2551,9 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -2569,6 +2623,9 @@
|
|||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -2663,6 +2720,10 @@
|
|||||||
--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;
|
||||||
}
|
}
|
||||||
@@ -2792,6 +2853,16 @@
|
|||||||
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) {
|
||||||
@@ -2862,6 +2933,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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) {
|
||||||
@@ -2967,6 +3045,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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) {
|
||||||
@@ -2989,6 +3074,12 @@
|
|||||||
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);
|
||||||
@@ -3082,9 +3173,9 @@
|
|||||||
max-width: var(--container-xl);
|
max-width: var(--container-xl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sm\:rounded-lg {
|
.sm\:grid-cols-2 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
border-radius: var(--radius-lg);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sm\:rounded-t-lg {
|
.sm\:rounded-t-lg {
|
||||||
@@ -3232,6 +3323,11 @@
|
|||||||
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);
|
||||||
@@ -3799,6 +3895,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.\[\&\:\:-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);
|
||||||
@@ -5032,6 +5173,21 @@ 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);
|
||||||
@@ -5099,6 +5255,9 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
})();
|
||||||
+14
-4
@@ -2,6 +2,7 @@ from django.urls import path
|
|||||||
|
|
||||||
from games.views import (
|
from games.views import (
|
||||||
device,
|
device,
|
||||||
|
filter_presets,
|
||||||
game,
|
game,
|
||||||
general,
|
general,
|
||||||
platform,
|
platform,
|
||||||
@@ -160,9 +161,18 @@ 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(
|
||||||
"stats/<int:year>",
|
"filter/presets/<int:preset_id>/delete",
|
||||||
general.stats,
|
filter_presets.delete_preset,
|
||||||
name="stats_by_year",
|
name="delete_preset",
|
||||||
),
|
),
|
||||||
]
|
path(
|
||||||
|
"filter/presets/<int:preset_id>/load",
|
||||||
|
filter_presets.load_preset,
|
||||||
|
name="load_preset",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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)}"
|
||||||
|
)
|
||||||
+44
-21
@@ -18,6 +18,7 @@ from common.components import (
|
|||||||
Component,
|
Component,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
|
FilterBar,
|
||||||
GameStatus,
|
GameStatus,
|
||||||
GameStatusSelector,
|
GameStatusSelector,
|
||||||
H1,
|
H1,
|
||||||
@@ -42,6 +43,7 @@ from common.time import (
|
|||||||
timeformat,
|
timeformat,
|
||||||
)
|
)
|
||||||
from common.utils import build_dynamic_filter, paginate, safe_division, truncate
|
from common.utils import build_dynamic_filter, paginate, 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
|
||||||
@@ -51,26 +53,35 @@ 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:
|
||||||
games = Game.objects.order_by("-created_at")
|
games = Game.objects.order_by("-created_at")
|
||||||
search_string = request.GET.get("search_string", search_string)
|
|
||||||
if search_string != "":
|
# ── Structured filter (Stash-style JSON) ──
|
||||||
filters = [
|
filter_json = request.GET.get("filter", "")
|
||||||
Q(name__icontains=search_string),
|
if filter_json:
|
||||||
Q(sort_name__icontains=search_string),
|
game_filter = parse_game_filter(filter_json)
|
||||||
Q(platform__name__icontains=search_string),
|
if game_filter is not None:
|
||||||
]
|
games = games.filter(game_filter.to_q())
|
||||||
try:
|
else:
|
||||||
year_value = int(search_string)
|
# ── Legacy free-text search ──
|
||||||
except ValueError:
|
search_string = request.GET.get("search_string", search_string)
|
||||||
year_value = None
|
if search_string != "":
|
||||||
if year_value:
|
filters = [
|
||||||
filters.append(Q(year_released=year_value))
|
Q(name__icontains=search_string),
|
||||||
search_string_parts = search_string.split()
|
Q(sort_name__icontains=search_string),
|
||||||
# only search for status if it exactly matches and is the only word
|
Q(platform__name__icontains=search_string),
|
||||||
if len(search_string_parts) == 1:
|
]
|
||||||
if search_string.title() in Game.Status.labels:
|
try:
|
||||||
search_status = Game.Status[search_string.upper()]
|
year_value = int(search_string)
|
||||||
filters.append(Q(status=search_status))
|
except ValueError:
|
||||||
games = games.filter(build_dynamic_filter(filters, "|"))
|
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)
|
games, page_obj, elided_page_range = paginate(request, games)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -126,7 +137,19 @@ 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,
|
||||||
)
|
)
|
||||||
return render_page(request, content, title="Manage games")
|
# Prepend the filter bar above the table
|
||||||
|
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
|
||||||
|
|||||||
+24
-5
@@ -12,7 +12,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
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
@@ -95,9 +95,16 @@ def _render_purchase_row(purchase):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_purchases(request: HttpRequest) -> HttpResponse:
|
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||||
purchases, page_obj, elided_page_range = paginate(
|
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
|
||||||
request, Purchase.objects.order_by("-date_purchased", "-created_at")
|
|
||||||
)
|
filter_json = request.GET.get("filter", "")
|
||||||
|
if filter_json:
|
||||||
|
from games.filters import parse_purchase_filter
|
||||||
|
pf = parse_purchase_filter(filter_json)
|
||||||
|
if pf is not None:
|
||||||
|
purchases = purchases.filter(pf.to_q())
|
||||||
|
|
||||||
|
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A(
|
"header_action": A(
|
||||||
@@ -121,7 +128,19 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return render_page(request, content, title="Manage purchases")
|
from common.components import PurchaseFilterBar, ModuleScript
|
||||||
|
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:
|
||||||
|
|||||||
+33
-10
@@ -40,15 +40,25 @@ from games.models import Device, Game, Session
|
|||||||
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||||
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)
|
|
||||||
if search_string != "":
|
# ── Structured filter (JSON) ──
|
||||||
sessions = sessions.filter(
|
filter_json = request.GET.get("filter", "")
|
||||||
Q(game__name__icontains=search_string)
|
if filter_json:
|
||||||
| Q(game__name__icontains=search_string)
|
from games.filters import parse_session_filter
|
||||||
| Q(game__platform__name__icontains=search_string)
|
session_filter = parse_session_filter(filter_json)
|
||||||
| Q(device__name__icontains=search_string)
|
if session_filter is not None:
|
||||||
| Q(device__type__icontains=search_string)
|
sessions = sessions.filter(session_filter.to_q())
|
||||||
)
|
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:
|
||||||
@@ -157,7 +167,20 @@ 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,
|
||||||
)
|
)
|
||||||
return render_page(request, content, title="Manage sessions")
|
from common.components import SessionFilterBar
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user