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