From b6864e59ce974581509c0f810da9b09e45854a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 6 Jun 2026 12:13:04 +0200 Subject: [PATCH] Add filters --- _add_search.py | 48 ++ common/components.py | 519 ++++++++++++++++++++- common/criteria.py | 451 ++++++++++++++++++ games/filters.py | 384 +++++++++++++++ games/forms.py | 3 +- games/migrations/0017_add_filter_preset.py | 29 ++ games/models.py | 30 ++ games/static/base.css | 163 ++++++- games/static/js/filter_bar.js | 380 +++++++++++++++ games/static/js/range_slider.js | 96 ++++ games/static/js/selectable_filter.js | 149 ++++++ games/urls.py | 18 +- games/views/filter_presets.py | 100 ++++ games/views/game.py | 65 ++- games/views/purchase.py | 29 +- games/views/session.py | 43 +- tests/test_filters.py | 280 +++++++++++ 17 files changed, 2743 insertions(+), 44 deletions(-) create mode 100644 _add_search.py create mode 100644 common/criteria.py create mode 100644 games/filters.py create mode 100644 games/migrations/0017_add_filter_preset.py create mode 100644 games/static/js/filter_bar.js create mode 100644 games/static/js/range_slider.js create mode 100644 games/static/js/selectable_filter.js create mode 100644 games/views/filter_presets.py create mode 100644 tests/test_filters.py diff --git a/_add_search.py b/_add_search.py new file mode 100644 index 0000000..d965066 --- /dev/null +++ b/_add_search.py @@ -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") diff --git a/common/components.py b/common/components.py index 0f98499..26fb8a8 100644 --- a/common/components.py +++ b/common/components.py @@ -7,6 +7,7 @@ from django.template.defaultfilters import floatformat from django.templatetags.static import static from django.urls import reverse from django.utils.html import conditional_escape, escape +from django.db import models from django.utils.safestring import SafeText, mark_safe from common.icons import get_icon @@ -951,7 +952,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText: {"".join(f"
  • {game.name}
  • " for game in purchase.games.all())} """ - 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 == "": raise ValueError("link_content is empty!!") a_content = Div( @@ -1171,3 +1172,519 @@ def _dropdown_button_html(button_content: str, list_items: str) -> str: "" "" ) + + +# ── 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'' + f'' + ), + ], + ) + + 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(''), + "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'' + f"{mod_label} " + ) + else: + inactive_mod_html += ( + f'
    ' + f'{mod_label}
    ' + ) + + selected_html = "" + for val in selected: + label = _find_label(options, val) + selected_html += ( + f'' + f'\u2713 {escape(label)}' + f' ' + ) + for val in excluded: + label = _find_label(options, val) + selected_html += ( + f'' + f'\u2717 {escape(label)}' + f' ' + ) + + options_html = "" + for val, label in options: + options_html += ( + f'
    ' + f'{escape(label)}' + f'' + f'' + f'' + f"
    " + ) + + 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('
    '+f'
    '), + ]) + + 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(''), "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('
    '+f'
    '), + ]) + + 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(''), "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..."])]), + ]), + ]), + ]), + ]) diff --git a/common/criteria.py b/common/criteria.py new file mode 100644 index 0000000..50be0f8 --- /dev/null +++ b/common/criteria.py @@ -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()) diff --git a/games/filters.py b/games/filters.py new file mode 100644 index 0000000..350ee23 --- /dev/null +++ b/games/filters.py @@ -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) diff --git a/games/forms.py b/games/forms.py index 0f182b0..799527d 100644 --- a/games/forms.py +++ b/games/forms.py @@ -43,7 +43,7 @@ class SessionForm(forms.ModelForm): ), 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( required=False, @@ -104,6 +104,7 @@ class PurchaseForm(forms.ModelForm): "hx-swap": "outerHTML", } ) + self.fields["platform"].queryset = Platform.objects.order_by("name") games = MultipleGameChoiceField( queryset=Game.objects.order_by("sort_name"), diff --git a/games/migrations/0017_add_filter_preset.py b/games/migrations/0017_add_filter_preset.py new file mode 100644 index 0000000..e7064e8 --- /dev/null +++ b/games/migrations/0017_add_filter_preset.py @@ -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'], + }, + ), + ] diff --git a/games/models.py b/games/models.py index 7839525..c4ecf5c 100644 --- a/games/models.py +++ b/games/models.py @@ -478,3 +478,33 @@ class GameStatusChange(models.Model): class Meta: 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()})" diff --git a/games/static/base.css b/games/static/base.css index 69c934e..2816dc2 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -826,6 +826,9 @@ .top-0 { top: calc(var(--spacing) * 0); } + .top-1\/2 { + top: calc(1 / 2 * 100%); + } .top-3 { top: calc(var(--spacing) * 3); } @@ -1273,6 +1276,9 @@ margin-left: -10px !important; } } + .ml-4 { + margin-left: calc(var(--spacing) * 4); + } .ml-auto { margin-left: auto; } @@ -1431,6 +1437,9 @@ width: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6); } + .h-2 { + height: calc(var(--spacing) * 2); + } .h-2\.5 { height: calc(var(--spacing) * 2.5); } @@ -1461,9 +1470,15 @@ .h-full { height: 100%; } + .max-h-40 { + max-height: calc(var(--spacing) * 40); + } .max-h-full { max-height: 100%; } + .min-h-\[28px\] { + min-height: 28px; + } .min-h-screen { min-height: 100vh; } @@ -1656,6 +1671,10 @@ --tw-translate-x: 100%; 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 { --tw-translate-y: -100%; translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1694,6 +1713,12 @@ .list-disc { list-style-type: disc; } + .appearance-none { + appearance: none; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } .grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -1826,6 +1851,9 @@ .rounded-base { border-radius: var(--radius-base); } + .rounded-full { + border-radius: calc(infinity * 1px); + } .rounded-lg { border-radius: var(--radius-lg); } @@ -1888,6 +1916,10 @@ border-style: var(--tw-border-style) !important; border-width: 0px !important; } + .border-2 { + border-style: var(--tw-border-style); + border-width: 2px; + } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; @@ -1984,9 +2016,15 @@ .border-red-200 { border-color: var(--color-red-200); } + .border-red-500 { + border-color: var(--color-red-500); + } .border-transparent { border-color: transparent; } + .border-white { + border-color: var(--color-white); + } .apexcharts-active { .apexcharts-canvas .apexcharts-tooltip-series-group& .apexcharts-tooltip-y-group { padding: 0 !important; @@ -2090,6 +2128,12 @@ .bg-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 { background-color: var(--color-neutral-tertiary-medium); } @@ -2287,6 +2331,9 @@ color: heading !important; } } + .pb-1 { + padding-bottom: calc(var(--spacing) * 1); + } .pb-16 { padding-bottom: calc(var(--spacing) * 16); } @@ -2446,6 +2493,10 @@ --tw-tracking: var(--tracking-tight); letter-spacing: var(--tracking-tight); } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } .text-balance { text-wrap: balance; } @@ -2500,6 +2551,9 @@ .text-body { color: var(--color-body); } + .text-brand { + color: var(--color-brand); + } .text-fg-brand { color: var(--color-fg-brand); } @@ -2569,6 +2623,9 @@ .uppercase { text-transform: uppercase; } + .italic { + font-style: italic; + } .no-underline\! { text-decoration-line: none !important; } @@ -2663,6 +2720,10 @@ --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } + .select-none { + -webkit-user-select: none; + user-select: none; + } .\[program\:caddy\] { program: caddy; } @@ -2792,6 +2853,16 @@ 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 { @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 { @media (hover: hover) { @@ -2967,6 +3045,13 @@ } } } + .hover\:text-red-700 { + &:hover { + @media (hover: hover) { + color: var(--color-red-700); + } + } + } .hover\:text-white { &:hover { @media (hover: hover) { @@ -2989,6 +3074,12 @@ 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 { --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); } } - .sm\:rounded-lg { + .sm\:grid-cols-2 { @media (width >= 40rem) { - border-radius: var(--radius-lg); + grid-template-columns: repeat(2, minmax(0, 1fr)); } } .sm\:rounded-t-lg { @@ -3232,6 +3323,11 @@ 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\) { @media (width >= 80rem) { 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 { border-start-start-radius: var(--radius-lg); @@ -5032,6 +5173,21 @@ form input:disabled, select:disabled, textarea:disabled { syntax: "*"; 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 { to { transform: rotate(360deg); @@ -5099,6 +5255,9 @@ form input:disabled, select:disabled, textarea:disabled { --tw-backdrop-sepia: initial; --tw-duration: initial; --tw-ease: initial; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; } } } diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js new file mode 100644 index 0000000..44d190b --- /dev/null +++ b/games/static/js/filter_bar.js @@ -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 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 = + 'Presets unavailable'; + 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
  • + 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 = + '
  • No saved presets
  • '; + } + }) + .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(); + }); +})(); diff --git a/games/static/js/range_slider.js b/games/static/js/range_slider.js new file mode 100644 index 0000000..b148e7f --- /dev/null +++ b/games/static/js/range_slider.js @@ -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; +})(); diff --git a/games/static/js/selectable_filter.js b/games/static/js/selectable_filter.js new file mode 100644 index 0000000..c6c8e52 --- /dev/null +++ b/games/static/js/selectable_filter.js @@ -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 = + '' + (type === "exclude" ? "✗ " : "✓ ") + label + "" + + ''; + 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); +})(); diff --git a/games/urls.py b/games/urls.py index d81a418..c8e4eb8 100644 --- a/games/urls.py +++ b/games/urls.py @@ -2,6 +2,7 @@ from django.urls import path from games.views import ( device, + filter_presets, game, general, platform, @@ -160,9 +161,18 @@ urlpatterns = [ name="list_statuschanges", ), path("stats/", general.stats_alltime, name="stats_alltime"), + path("stats/", 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( - "stats/", - general.stats, - name="stats_by_year", + "filter/presets//delete", + filter_presets.delete_preset, + name="delete_preset", ), -] + path( + "filter/presets//load", + filter_presets.load_preset, + name="load_preset", + ), +] \ No newline at end of file diff --git a/games/views/filter_presets.py b/games/views/filter_presets.py new file mode 100644 index 0000000..7781679 --- /dev/null +++ b/games/views/filter_presets.py @@ -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"
  • " + f'' + f"{preset.name}" + f'x' + f"
  • " + ) + + if not items: + items = [ + '
  • ' + "No saved presets
  • " + ] + + return HttpResponse( + mark_safe(f'
      {"".join(items)}
    ') + ) + + +@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)}" + ) diff --git a/games/views/game.py b/games/views/game.py index 2232ddd..c6a0f90 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -18,6 +18,7 @@ from common.components import ( Component, CsrfInput, Div, + FilterBar, GameStatus, GameStatusSelector, H1, @@ -42,6 +43,7 @@ from common.time import ( timeformat, ) 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.models import Game from games.views.general import use_custom_redirect @@ -51,26 +53,35 @@ from games.views.playevent import create_playevent_tabledata @login_required def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse: games = Game.objects.order_by("-created_at") - search_string = request.GET.get("search_string", search_string) - if search_string != "": - filters = [ - Q(name__icontains=search_string), - Q(sort_name__icontains=search_string), - Q(platform__name__icontains=search_string), - ] - try: - year_value = int(search_string) - except ValueError: - year_value = None - if year_value: - filters.append(Q(year_released=year_value)) - search_string_parts = search_string.split() - # only search for status if it exactly matches and is the only word - if len(search_string_parts) == 1: - if search_string.title() in Game.Status.labels: - search_status = Game.Status[search_string.upper()] - filters.append(Q(status=search_status)) - games = games.filter(build_dynamic_filter(filters, "|")) + + # ── Structured filter (Stash-style JSON) ── + filter_json = request.GET.get("filter", "") + if filter_json: + game_filter = parse_game_filter(filter_json) + if game_filter is not None: + games = games.filter(game_filter.to_q()) + else: + # ── Legacy free-text search ── + search_string = request.GET.get("search_string", search_string) + if search_string != "": + filters = [ + Q(name__icontains=search_string), + Q(sort_name__icontains=search_string), + Q(platform__name__icontains=search_string), + ] + try: + year_value = int(search_string) + except ValueError: + year_value = None + if year_value: + filters.append(Q(year_released=year_value)) + search_string_parts = search_string.split() + if len(search_string_parts) == 1: + if search_string.title() in Game.Status.labels: + search_status = Game.Status[search_string.upper()] + filters.append(Q(status=search_status)) + games = games.filter(build_dynamic_filter(filters, "|")) + games, page_obj, elided_page_range = paginate(request, games) data = { @@ -126,7 +137,19 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse: elided_page_range=elided_page_range, 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 diff --git a/games/views/purchase.py b/games/views/purchase.py index 60bef0f..b699b14 100644 --- a/games/views/purchase.py +++ b/games/views/purchase.py @@ -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 floatformat -from django.utils.safestring import SafeText +from django.utils.safestring import SafeText, mark_safe from common.components import ( A, @@ -95,9 +95,16 @@ def _render_purchase_row(purchase): @login_required def list_purchases(request: HttpRequest) -> HttpResponse: - purchases, page_obj, elided_page_range = paginate( - request, Purchase.objects.order_by("-date_purchased", "-created_at") - ) + purchases = 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 = { "header_action": A( @@ -121,7 +128,19 @@ def list_purchases(request: HttpRequest) -> HttpResponse: elided_page_range=elided_page_range, 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: diff --git a/games/views/session.py b/games/views/session.py index 8f76788..f54e8cd 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -40,15 +40,25 @@ from games.models import Device, Game, Session def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse: sessions = Session.objects.order_by("-timestamp_start", "created_at") device_list = Device.objects.order_by("name") - 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) - ) + + # ── Structured filter (JSON) ── + filter_json = request.GET.get("filter", "") + if filter_json: + from games.filters import parse_session_filter + session_filter = parse_session_filter(filter_json) + if session_filter is not None: + 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: last_session = sessions.latest() except Session.DoesNotExist: @@ -157,7 +167,20 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse elided_page_range=elided_page_range, 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 diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..e79feae --- /dev/null +++ b/tests/test_filters.py @@ -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