diff --git a/_add_search.py b/_add_search.py deleted file mode 100644 index d965066..0000000 --- a/_add_search.py +++ /dev/null @@ -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") diff --git a/_cf.py b/_cf.py new file mode 100644 index 0000000..2532dd1 --- /dev/null +++ b/_cf.py @@ -0,0 +1,1950 @@ +import hashlib +from functools import lru_cache +from typing import Any + +from django.db import models +from django.middleware.csrf import get_token +from django.template.defaultfilters import floatformat +from django.templatetags.static import static +from django.urls import reverse +from django.utils.html import conditional_escape, escape +from django.utils.safestring import SafeText, mark_safe + +from common.icons import get_icon +from common.utils import truncate +from games.models import Game, Purchase, Session + +HTMLAttribute = tuple[str, str | int | bool] +HTMLTag = str + +_COLOR_CLASSES = { + "blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", + "red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white", + "gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border", + "green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white", +} + +_SIZE_CLASSES = { + "xs": "px-3 py-2 text-xs shadow-xs", + "sm": "px-3 py-2 text-sm", + "base": "px-5 py-2.5 text-sm", + "lg": "px-5 py-3 text-base", + "xl": "px-6 py-3.5 text-base", +} + + +@lru_cache(maxsize=4096) +def _render_element( + tag_name: str, + attrs_key: tuple[tuple[str, str], ...], + 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 + (attribute values are always escaped). `children_key` is (child, is_safe) + pairs: SafeText children pass through, plain strings are escaped. The + `is_safe` flag is part of the key on purpose — otherwise a safe ``""`` + and an unsafe ``""`` (equal as strings) would collide and one would + render with the wrong escaping. + """ + children_blob = "\n".join( + child if is_safe else escape(child) for child, is_safe in children_key + ) + 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}" + + +def Component( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, + tag_name: str = "", +) -> 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 [] + children = children or [] + if not tag_name: + raise ValueError("tag_name is required.") + if isinstance(children, str): + children = [children] + attrs_key = tuple((name, str(value)) for name, value in attributes) + children_key = tuple((child, isinstance(child, SafeText)) for child in children) + return mark_safe(_render_element(tag_name, attrs_key, children_key)) + + +def randomid(seed: str = "", content: str = "", length: int = 10) -> str: + if not seed and not content: + return seed + hash_input = f"{seed}:{content}" if seed else content + content_hash = hashlib.sha1(hash_input.encode()).hexdigest() + base = ( + content_hash[:length] + if not seed + else content_hash[: max(0, length - len(seed))] + ) + return seed + base + + +def _popover_html( + id: str, + popover_content: str, + wrapped_content: str = "", + wrapped_classes: str = "", + slot: str = "", +) -> SafeText: + """Generate popover HTML using Component(tag_name=...). + + Single source of truth for popover HTML structure. + Used by Popover() and the python_popover template tag bridge. + """ + display_content = wrapped_content if wrapped_content else slot + + span = Component( + tag_name="span", + attributes=[ + ("data-popover-target", id), + ("class", wrapped_classes), + ], + children=[display_content] if display_content else [], + ) + + popover_tooltip_class = ( + "absolute z-10 invisible inline-block text-sm text-white " + "transition-opacity duration-300 bg-white border border-purple-200 " + "rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " + "dark:bg-purple-800" + ) + + div = Component( + tag_name="div", + attributes=[ + ("data-popover", ""), + ("id", id), + ("role", "tooltip"), + ("class", popover_tooltip_class), + ], + children=[ + Component( + tag_name="div", + attributes=[("class", "px-3 py-2")], + children=[popover_content], + ), + Component(tag_name="div", attributes=[("data-popper-arrow", "")]), + mark_safe( # nosec — intentional HTML comment for Tailwind JIT + "" + ), + Component( + tag_name="span", + attributes=[("class", "hidden decoration-dotted")], + ), + ], + ) + + return mark_safe(span + "\n" + div) + + +def Popover( + popover_content: str, + wrapped_content: str = "", + wrapped_classes: str = "", + children: list[HTMLTag] | None = None, + attributes: list[HTMLAttribute] | None = None, + id: str = "", +) -> str: + children = children or [] + if not wrapped_content and not children: + raise ValueError("One of wrapped_content or children is required.") + if not id: + id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") + + slot = mark_safe("\n".join(children)) + return _popover_html( + id=id, + popover_content=popover_content, + wrapped_content=wrapped_content, + wrapped_classes=wrapped_classes, + slot=slot, + ) + + +def PopoverTruncated( + input_string: str, + popover_content: str = "", + popover_if_not_truncated: bool = False, + length: int = 30, + ellipsis: str = "…", + endpart: str = "", +) -> str: + """ + Returns `input_string` truncated after `length` of characters + and displays the untruncated text in a popover HTML element. + The truncated text ends in `ellipsis`, and optionally + an always-visible `endpart` can be specified. + `popover_content` can be specified if: + 1. It needs to be always displayed regardless if text is truncated. + 2. It needs to differ from `input_string`. + """ + if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string: + return Popover( + wrapped_content=truncated, + popover_content=popover_content if popover_content else input_string, + ) + else: + if popover_content and popover_if_not_truncated: + return Popover( + wrapped_content=input_string, + popover_content=popover_content if popover_content else "", + ) + else: + return input_string + + +def A( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, + url_name: str | None = None, + href: str | None = None, +) -> SafeText: + """ + Returns an anchor tag. + + Accepts one of two mutually-exclusive URL specifications: + - url_name: URL pattern name, resolved via reverse() + - href: Literal path string passed through as-is + """ + attributes = attributes or [] + children = children or [] + if url_name is not None and href is not None: + raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") + + additional_attributes = [] + if url_name is not None: + additional_attributes = [("href", reverse(url_name))] + elif href is not None: + additional_attributes = [("href", href)] + return Component( + tag_name="a", attributes=attributes + additional_attributes, children=children + ) + + +def Button( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, + size: str = "base", + icon: bool = False, + color: str = "blue", + type: str = "button", + hx_get: str = "", + hx_target: str = "", + hx_swap: str = "", + title: str = "", + onclick: str = "", + name: str = "", +) -> SafeText: + attributes = attributes or [] + children = children or [] + + # Separate custom class from other generic attributes + custom_class = "" + other_attrs: list[HTMLAttribute] = [] + for attr_name, attr_value in attributes: + if attr_name == "class": + custom_class = str(attr_value) + else: + other_attrs.append((attr_name, attr_value)) + + # Build class string: custom class first, then base, color, size, icon + class_parts: list[str] = [] + if custom_class: + class_parts.append(custom_class) + class_parts.append( + "hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 " + "font-medium mb-2 me-2 rounded-base" + ) + class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"])) + class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"])) + if icon: + class_parts.append("inline-flex text-center items-center gap-2") + + # Build the full attribute list for the button tag + button_attrs: list[HTMLAttribute] = [ + ("type", type), + ("class", " ".join(class_parts)), + ] + if hx_get: + button_attrs.append(("hx-get", hx_get)) + if hx_target: + button_attrs.append(("hx-target", hx_target)) + if hx_swap: + button_attrs.append(("hx-swap", hx_swap)) + if title: + button_attrs.append(("title", title)) + if onclick: + button_attrs.append(("onclick", onclick)) + if name: + button_attrs.append(("name", name)) + button_attrs.extend(other_attrs) + + return Component( + tag_name="button", + attributes=button_attrs, + children=children, + ) + + +_GROUP_BUTTON_COLORS = { + "gray": ( + "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " + "border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 " + "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " + "dark:bg-gray-800 dark:border-gray-700 dark:text-white " + "dark:hover:text-white dark:hover:bg-gray-700 " + "dark:focus:ring-blue-500 dark:focus:text-white" + ), + "red": ( + "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " + "border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 " + "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " + "dark:bg-gray-800 dark:border-gray-700 dark:text-white " + "dark:hover:text-white dark:hover:border-red-700 " + "dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white" + ), + "green": ( + "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " + "border-gray-200 hover:bg-green-500 hover:border-green-600 " + "hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 " + "focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " + "dark:text-white dark:hover:text-white dark:hover:border-green-700 " + "dark:hover:bg-green-600 dark:focus:ring-green-500 " + "dark:focus:text-white" + ), +} + + +def _button_group_button( + href: str, + slot: str, + color: str = "gray", + title: str = "", + hx_get: str = "", + hx_target: str = "", +) -> SafeText: + """Generate a single button-group button (inner " + "" + ) + + +# ── Filter bar (shared shell + per-entity field builders) ─────────────────── + +_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide" +_FILTER_INPUT_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" +) +_FILTER_CHECKBOX_CLASS = ( + "rounded border-default-medium bg-neutral-secondary-medium " + "text-brand focus:ring-brand" +) +_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4" + + +def _filter_parse(filter_json: str) -> dict: + if not filter_json: + return {} + try: + import json + + loaded = json.loads(filter_json) + return loaded if isinstance(loaded, dict) else {} + except (ValueError, TypeError): + return {} + + +def _filter_get_choice(existing: dict, 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 "" + + +def _filter_mins_to_hrs(val) -> str: + 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}" + + +def _filter_field(label: str, widget) -> SafeText: + """A labelled filter field:
{widget}
.""" + return Component( + tag_name="div", + attributes=[("class", "flex flex-col gap-1")], + children=[ + Component( + tag_name="label", + attributes=[("class", _FILTER_LABEL_CLASS)], + children=[label], + ), + widget, + ], + ) + + +def _filter_number(label, name, value="", placeholder="") -> SafeText: + return _filter_field( + label, + Component( + tag_name="input", + attributes=[ + ("type", "number"), + ("name", escape(name)), + ("id", escape(name)), + ("value", escape(value)), + ("placeholder", escape(placeholder)), + ("class", _FILTER_INPUT_CLASS), + ], + ), + ) + + +def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: + return Component( + tag_name="label", + attributes=[("class", "flex items-center gap-2 text-sm text-heading")], + children=[ + Component( + tag_name="input", + attributes=[ + ("type", "checkbox"), + ("name", name), + ("value", "1"), + *([("checked", "true")] if checked else []), + ("class", _FILTER_CHECKBOX_CLASS), + ], + ), + label, + ], + ) + + +def _filter_range_inputs(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"): + """Twin slider (used by the game filter bar).""" + mv = min_v or str(dmin) + xv = max_v or str(dmax) + return Component( + tag_name="div", + attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")], + children=[ + mark_safe( + f'' + f'' + ), + ], + ) + + +def _filter_range_handles(cls, min_id, max_id, lo, hi, step="1"): + """Handle-based slider (used by the session & purchase filter bars).""" + 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(step)), + ], + children=[ + mark_safe( + '
' + + f'
' + ), + ], + ) + + +_FILTER_FORM_ID = "filter-bar-form" +_FILTER_INPUT_ID = "filter-json-input" + + +def _filter_collapse_button() -> SafeText: + return Component( + tag_name="button", + attributes=[ + ("type", "button"), + ( + "onclick", + "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", + ), + ( + "class", + "flex items-center gap-2 text-sm font-medium text-body " + "hover:text-heading mb-2", + ), + ], + children=[ + mark_safe( + '' + ), + "Filters", + ], + ) + + +def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: + return 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('{_FILTER_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('{_FILTER_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..."], + ), + ], + ), + ], + ) + + +def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText: + """Shared collapsible filter-bar chrome. `fields` is the per-entity body + (grids, sliders, checkboxes); the shell adds the collapse toggle, the form, + the hidden filter-json input and the Apply/Clear/preset action row.""" + return Component( + tag_name="div", + attributes=[("id", "filter-bar"), ("class", "mb-6")], + children=[ + _filter_collapse_button(), + 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", _FILTER_FORM_ID), + ("onsubmit", "return applyFilterBar(event)"), + ], + children=[ + Component( + tag_name="input", + attributes=[ + ("type", "hidden"), + ("id", _FILTER_INPUT_ID), + ("name", "filter"), + # NB: Component escapes attribute values, so the + # raw JSON is passed through (no double-escape). + ("value", filter_json), + ], + ), + *fields, + _filter_action_row(preset_list_url, preset_save_url), + ], + ), + ], + ), + ], + ) + + +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: + """Collapsible filter bar for the Game list.""" + 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 = _filter_parse(filter_json) + status_sel, status_excl, status_mod = _filter_get_choice(existing, "status") + plat_sel, plat_excl, plat_mod = _filter_get_choice(existing, "platform") + plat_opts_str = [(str(k), v) for k, v in platform_options] + + 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 = ( + _filter_mins_to_hrs(playtime.get("value", "")) + if isinstance(playtime, dict) + else "" + ) + playtime_max = ( + _filter_mins_to_hrs(playtime.get("value2", "")) + if isinstance(playtime, dict) + else "" + ) + + 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 + ) + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Status", + SelectableFilter( + "status", + status_options, + status_sel, + status_excl, + status_mod, + nullable=not Game._meta.get_field("status").has_default(), + ), + ), + _filter_field( + "Platform", + SelectableFilter( + "platform", + plat_opts_str, + plat_sel, + plat_excl, + plat_mod, + nullable=Game._meta.get_field("platform").null, + ), + ), + _filter_number("Year Min", "filter-year-min", year_min, "e.g. 2020"), + _filter_number("Year Max", "filter-year-max", year_max, "e.g. 2024"), + ], + ), + _filter_range_inputs( + "year-range", + "filter-year-min", + "filter-year-max", + year_min, + year_max, + yr_data_min, + yr_data_max, + ), + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_number( + "Playtime Min (hrs)", "filter-playtime-min", playtime_min, "e.g. 1" + ), + _filter_number( + "Playtime Max (hrs)", + "filter-playtime-max", + playtime_max, + "e.g. 100", + ), + Component( + tag_name="div", + attributes=[("class", "flex items-end pb-1")], + children=[ + _filter_checkbox("filter-mastered", "Mastered", mastered_val) + ], + ), + ], + ), + _filter_range_inputs( + "playtime-range", + "filter-playtime-min", + "filter-playtime-max", + playtime_min or "0", + playtime_max or str(pt_data_max), + 0, + pt_data_max, + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +# ── SelectableFilter widget ──────────────────────────────────────────────── + + +def SelectableFilter( + field_name: str, + options: list[tuple[str, str]], + selected: list[str] | None = None, + excluded: list[str] | None = None, + modifier: str = "", + nullable: bool = True, +) -> "SafeText": + """Stash-style selectable filter with search, include/exclude, modifier tags.""" + selected = selected or [] + excluded = excluded or [] + + active_mod_html = "" + inactive_mod_html = "" + mod_opts = [("NOT_NULL", "(Any)")] + if nullable: + mod_opts.append(("IS_NULL", "(None)")) + for mod_val, mod_label in mod_opts: + if modifier == mod_val: + active_mod_html = ( + f'' + f"{mod_label} " + ) + else: + inactive_mod_html += ( + f'
' + f'{mod_label}
' + ) + + selected_html = "" + for val in selected: + label = _find_label(options, val) + selected_html += ( + f'' + f'\u2713 {escape(label)}' + f' ' + ) + for val in excluded: + label = _find_label(options, val) + selected_html += ( + f'' + f'\u2717 {escape(label)}' + f' ' + ) + + options_html = "" + for val, label in options: + options_html += ( + f'
' + f'{escape(label)}' + f'' + f'' + f'' + f"
" + ) + + return Component( + tag_name="div", + attributes=[ + ( + "class", + "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium", + ), + ("data-selectable-filter", field_name), + *([("data-modifier", modifier)] if modifier else []), + ], + children=[ + Component( + tag_name="div", + attributes=[ + ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"), + ], + children=[mark_safe(active_mod_html + selected_html)], + ), + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ( + "class", + "sf-search block w-full border-0 border-t border-default-medium " + "bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden", + ), + ("placeholder", "Search\u2026"), + ], + ), + Component( + tag_name="div", + attributes=[ + ("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"), + ], + 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="" +) -> SafeText: + """Collapsible filter bar for the Session list.""" + from games.models import Device, Game, 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 = _filter_parse(filter_json) + gs, ge, gm = _filter_get_choice(existing, "game") + ds, de, dm = _filter_get_choice(existing, "device") + + dur = existing.get("duration_minutes", {}) + dmin = _filter_mins_to_hrs(dur.get("value", "")) if isinstance(dur, dict) else "" + dmax = _filter_mins_to_hrs(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 + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Game", + SelectableFilter( + "game", + game_opts, + gs, + ge, + gm, + nullable=not Game._meta.get_field("name").has_default(), + ), + ), + _filter_field( + "Device", + SelectableFilter( + "device", + dev_opts, + ds, + de, + dm, + nullable=Session._meta.get_field("device").null, + ), + ), + _filter_number( + "Duration Min (hrs)", "filter-playtime-min", dmin, "e.g. 0.5" + ), + _filter_number( + "Duration Max (hrs)", "filter-playtime-max", dmax, "e.g. 10" + ), + ], + ), + _filter_range_handles( + "dur-range", "filter-playtime-min", "filter-playtime-max", 0, ddm + ), + Component( + tag_name="div", + attributes=[("class", "flex gap-4 mb-4")], + children=[ + _filter_checkbox("filter-emulated", "Emulated", em), + _filter_checkbox("filter-active", "Active", ac), + ], + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def PurchaseFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the Purchase list.""" + 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 = _filter_parse(filter_json) + gs, ge, gm = _filter_get_choice(existing, "games") + ps, pe, pm = _filter_get_choice(existing, "platform") + ts, te, tm = _filter_get_choice(existing, "type") + os_, oe, om = _filter_get_choice(existing, "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 + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Game", + SelectableFilter("games", game_opts, gs, ge, gm, nullable=False), + ), + _filter_field( + "Platform", + SelectableFilter( + "platform", + plat_opts, + ps, + pe, + pm, + nullable=Purchase._meta.get_field("platform").null, + ), + ), + _filter_field( + "Type", + SelectableFilter( + "type", + type_opts, + ts, + te, + tm, + nullable=not Purchase._meta.get_field("type").has_default(), + ), + ), + _filter_field( + "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", _FILTER_GRID_CLASS)], + children=[ + _filter_number("Price Min", "filter-price-min", pmin, "0.00"), + _filter_number("Price Max", "filter-price-max", pmax, "100.00"), + _filter_checkbox("filter-refunded", "Refunded", rf), + ], + ), + _filter_range_handles( + "price-range", "filter-price-min", "filter-price-max", plo, phi + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) diff --git a/common/components.py b/common/components.py deleted file mode 100644 index e892b06..0000000 --- a/common/components.py +++ /dev/null @@ -1,2633 +0,0 @@ -import hashlib -from functools import lru_cache -from typing import Any - -from django.db import models -from django.middleware.csrf import get_token -from django.template.defaultfilters import floatformat -from django.templatetags.static import static -from django.urls import reverse -from django.utils.html import conditional_escape, escape -from django.utils.safestring import SafeText, mark_safe - -from common.icons import get_icon -from common.utils import truncate -from games.models import Game, Purchase, Session - -HTMLAttribute = tuple[str, str | int | bool] -HTMLTag = str - -_COLOR_CLASSES = { - "blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", - "red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white", - "gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border", - "green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white", -} - -_SIZE_CLASSES = { - "xs": "px-3 py-2 text-xs shadow-xs", - "sm": "px-3 py-2 text-sm", - "base": "px-5 py-2.5 text-sm", - "lg": "px-5 py-3 text-base", - "xl": "px-6 py-3.5 text-base", -} - - -@lru_cache(maxsize=4096) -def _render_element( - tag_name: str, - attrs_key: tuple[tuple[str, str], ...], - 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 - (attribute values are always escaped). `children_key` is (child, is_safe) - pairs: SafeText children pass through, plain strings are escaped. The - `is_safe` flag is part of the key on purpose — otherwise a safe ``""`` - and an unsafe ``""`` (equal as strings) would collide and one would - render with the wrong escaping. - """ - children_blob = "\n".join( - child if is_safe else escape(child) for child, is_safe in children_key - ) - 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}" - - -def Component( - attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, - tag_name: str = "", -) -> 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 [] - children = children or [] - if not tag_name: - raise ValueError("tag_name is required.") - if isinstance(children, str): - children = [children] - attrs_key = tuple((name, str(value)) for name, value in attributes) - children_key = tuple((child, isinstance(child, SafeText)) for child in children) - return mark_safe(_render_element(tag_name, attrs_key, children_key)) - - -def randomid(seed: str = "", content: str = "", length: int = 10) -> str: - if not seed and not content: - return seed - hash_input = f"{seed}:{content}" if seed else content - content_hash = hashlib.sha1(hash_input.encode()).hexdigest() - base = ( - content_hash[:length] - if not seed - else content_hash[: max(0, length - len(seed))] - ) - return seed + base - - -def _popover_html( - id: str, - popover_content: str, - wrapped_content: str = "", - wrapped_classes: str = "", - slot: str = "", -) -> SafeText: - """Generate popover HTML using Component(tag_name=...). - - Single source of truth for popover HTML structure. - Used by Popover() and the python_popover template tag bridge. - """ - display_content = wrapped_content if wrapped_content else slot - - span = Component( - tag_name="span", - attributes=[ - ("data-popover-target", id), - ("class", wrapped_classes), - ], - children=[display_content] if display_content else [], - ) - - popover_tooltip_class = ( - "absolute z-10 invisible inline-block text-sm text-white " - "transition-opacity duration-300 bg-white border border-purple-200 " - "rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " - "dark:bg-purple-800" - ) - - div = Component( - tag_name="div", - attributes=[ - ("data-popover", ""), - ("id", id), - ("role", "tooltip"), - ("class", popover_tooltip_class), - ], - children=[ - Component( - tag_name="div", - attributes=[("class", "px-3 py-2")], - children=[popover_content], - ), - Component(tag_name="div", attributes=[("data-popper-arrow", "")]), - mark_safe( # nosec — intentional HTML comment for Tailwind JIT - "" - ), - Component( - tag_name="span", - attributes=[("class", "hidden decoration-dotted")], - ), - ], - ) - - return mark_safe(span + "\n" + div) - - -def Popover( - popover_content: str, - wrapped_content: str = "", - wrapped_classes: str = "", - children: list[HTMLTag] | None = None, - attributes: list[HTMLAttribute] | None = None, - id: str = "", -) -> str: - children = children or [] - if not wrapped_content and not children: - raise ValueError("One of wrapped_content or children is required.") - if not id: - id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") - - slot = mark_safe("\n".join(children)) - return _popover_html( - id=id, - popover_content=popover_content, - wrapped_content=wrapped_content, - wrapped_classes=wrapped_classes, - slot=slot, - ) - - -def PopoverTruncated( - input_string: str, - popover_content: str = "", - popover_if_not_truncated: bool = False, - length: int = 30, - ellipsis: str = "…", - endpart: str = "", -) -> str: - """ - Returns `input_string` truncated after `length` of characters - and displays the untruncated text in a popover HTML element. - The truncated text ends in `ellipsis`, and optionally - an always-visible `endpart` can be specified. - `popover_content` can be specified if: - 1. It needs to be always displayed regardless if text is truncated. - 2. It needs to differ from `input_string`. - """ - if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string: - return Popover( - wrapped_content=truncated, - popover_content=popover_content if popover_content else input_string, - ) - else: - if popover_content and popover_if_not_truncated: - return Popover( - wrapped_content=input_string, - popover_content=popover_content if popover_content else "", - ) - else: - return input_string - - -def A( - attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, - url_name: str | None = None, - href: str | None = None, -) -> SafeText: - """ - Returns an anchor tag. - - Accepts one of two mutually-exclusive URL specifications: - - url_name: URL pattern name, resolved via reverse() - - href: Literal path string passed through as-is - """ - attributes = attributes or [] - children = children or [] - if url_name is not None and href is not None: - raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") - - additional_attributes = [] - if url_name is not None: - additional_attributes = [("href", reverse(url_name))] - elif href is not None: - additional_attributes = [("href", href)] - return Component( - tag_name="a", attributes=attributes + additional_attributes, children=children - ) - - -def Button( - attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, - size: str = "base", - icon: bool = False, - color: str = "blue", - type: str = "button", - hx_get: str = "", - hx_target: str = "", - hx_swap: str = "", - title: str = "", - onclick: str = "", - name: str = "", -) -> SafeText: - attributes = attributes or [] - children = children or [] - - # Separate custom class from other generic attributes - custom_class = "" - other_attrs: list[HTMLAttribute] = [] - for attr_name, attr_value in attributes: - if attr_name == "class": - custom_class = str(attr_value) - else: - other_attrs.append((attr_name, attr_value)) - - # Build class string: custom class first, then base, color, size, icon - class_parts: list[str] = [] - if custom_class: - class_parts.append(custom_class) - class_parts.append( - "hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 " - "font-medium mb-2 me-2 rounded-base" - ) - class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"])) - class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"])) - if icon: - class_parts.append("inline-flex text-center items-center gap-2") - - # Build the full attribute list for the button tag - button_attrs: list[HTMLAttribute] = [ - ("type", type), - ("class", " ".join(class_parts)), - ] - if hx_get: - button_attrs.append(("hx-get", hx_get)) - if hx_target: - button_attrs.append(("hx-target", hx_target)) - if hx_swap: - button_attrs.append(("hx-swap", hx_swap)) - if title: - button_attrs.append(("title", title)) - if onclick: - button_attrs.append(("onclick", onclick)) - if name: - button_attrs.append(("name", name)) - button_attrs.extend(other_attrs) - - return Component( - tag_name="button", - attributes=button_attrs, - children=children, - ) - - -_GROUP_BUTTON_COLORS = { - "gray": ( - "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " - "border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 " - "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " - "dark:bg-gray-800 dark:border-gray-700 dark:text-white " - "dark:hover:text-white dark:hover:bg-gray-700 " - "dark:focus:ring-blue-500 dark:focus:text-white" - ), - "red": ( - "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " - "border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 " - "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " - "dark:bg-gray-800 dark:border-gray-700 dark:text-white " - "dark:hover:text-white dark:hover:border-red-700 " - "dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white" - ), - "green": ( - "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " - "border-gray-200 hover:bg-green-500 hover:border-green-600 " - "hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 " - "focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " - "dark:text-white dark:hover:text-white dark:hover:border-green-700 " - "dark:hover:bg-green-600 dark:focus:ring-green-500 " - "dark:focus:text-white" - ), -} - - -def _button_group_button( - href: str, - slot: str, - color: str = "gray", - title: str = "", - hx_get: str = "", - hx_target: str = "", -) -> SafeText: - """Generate a single button-group button (inner " - "" - ) - - -# ── Filter bar ───────────────────────────────────────────────────────────── - - -def FilterBar( - filter_json: str = "", - status_options: list[tuple[str, str]] | None = None, - platform_options: list[tuple[int, str]] | None = None, - preset_list_url: str = "", - preset_save_url: str = "", -) -> "SafeText": - """Render a collapsible filter bar with SelectableFilter widgets.""" - from games.models import Game, Platform - - if status_options is None: - status_options = [(s.value, s.label) for s in Game.Status] - if platform_options is None: - platform_options = list( - Platform.objects.order_by("name").values_list("id", "name") - ) - - existing: dict = {} - if filter_json: - try: - import json - - existing = json.loads(filter_json) - except (json.JSONDecodeError, TypeError): - pass - - def _get_choice(field: str) -> tuple[list[str], list[str], str]: - raw = existing.get(field, {}) - if not isinstance(raw, dict): - return [], [], "" - val = raw.get("value", []) - excl = raw.get("excludes", []) - mod = raw.get("modifier", "") - if isinstance(val, str): - val = [val] - if isinstance(excl, str): - excl = [excl] - return [str(v) for v in (val or [])], [str(v) for v in (excl or [])], mod or "" - - status_sel, status_excl, status_mod = _get_choice("status") - plat_sel, plat_excl, plat_mod = _get_choice("platform") - plat_opts_str: list[tuple[str, str]] = [(str(k), v) for k, v in platform_options] - - def _mins_to_hrs(val): - if val is None or val == "" or val == 0: - return "" - try: - mins = int(val) - except (TypeError, ValueError): - return "" - if mins == 0: - return "" - hrs = mins / 60 - return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}" - - year_rel = existing.get("year_released", {}) - year_min = str(year_rel.get("value", "")) if isinstance(year_rel, dict) else "" - year_max = str(year_rel.get("value2", "")) if isinstance(year_rel, dict) else "" - mastered_val = ( - existing.get("mastered", {}).get("value", False) - if isinstance(existing.get("mastered"), dict) - else False - ) - playtime = existing.get("playtime_minutes", {}) - playtime_min = ( - _mins_to_hrs(playtime.get("value", "")) if isinstance(playtime, dict) else "" - ) - playtime_max = ( - _mins_to_hrs(playtime.get("value2", "")) if isinstance(playtime, dict) else "" - ) - - # DB-backed ranges for sliders - try: - year_agg = Game.objects.aggregate( - yr_min=models.Min("year_released"), yr_max=models.Max("year_released") - ) - except Exception: - year_agg = {} - try: - pt_agg = Game.objects.aggregate(pt_max=models.Max("playtime")) - except Exception: - pt_agg = {} - yr_data_min = max(int(year_agg.get("yr_min") or 1970), 1970) - yr_data_max = min(int(year_agg.get("yr_max") or 2030), 2030) - pt_data_max = ( - int((pt_agg.get("pt_max") or 0).total_seconds() / 3600) - if pt_agg.get("pt_max") - else 200 - ) - - form_id = "filter-bar-form" - filter_input_id = "filter-json-input" - - def _number(label, name, value="", placeholder=""): - return Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ), - ], - children=[label], - ), - Component( - tag_name="input", - attributes=[ - ("type", "number"), - ("name", escape(name)), - ("id", escape(name)), - ("value", escape(value)), - ("placeholder", escape(placeholder)), - ( - "class", - "block w-full rounded-base border border-default-medium " - "bg-neutral-secondary-medium text-sm text-heading p-2 " - "focus:ring-brand focus:border-brand", - ), - ], - ), - ], - ) - - def _range(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"): - mv = min_v or str(dmin) - xv = max_v or str(dmax) - return Component( - tag_name="div", - attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")], - children=[ - mark_safe( - f'' - f'' - ), - ], - ) - - return Component( - tag_name="div", - attributes=[("id", "filter-bar"), ("class", "mb-6")], - children=[ - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ( - "onclick", - "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", - ), - ( - "class", - "flex items-center gap-2 text-sm font-medium text-body " - "hover:text-heading mb-2", - ), - ], - children=[ - mark_safe( - '' - ), - "Filters", - ], - ), - Component( - tag_name="div", - attributes=[ - ("id", "filter-bar-body"), - ( - "class", - "hidden border border-default-medium rounded-base p-4 " - "bg-neutral-secondary-medium/50", - ), - ], - children=[ - Component( - tag_name="form", - attributes=[ - ("id", form_id), - ("onsubmit", "return applyFilterBar(event)"), - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "hidden"), - ("id", filter_input_id), - ("name", "filter"), - ("value", escape(filter_json)), - ], - ), - Component( - tag_name="div", - attributes=[ - ( - "class", - "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", - ), - ], - children=[ - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ), - ], - children=["Status"], - ), - SelectableFilter( - "status", - status_options, - status_sel, - status_excl, - status_mod, - nullable=not Game._meta.get_field( - "status" - ).has_default(), - ), - ], - ), - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ), - ], - children=["Platform"], - ), - SelectableFilter( - "platform", - plat_opts_str, - plat_sel, - plat_excl, - plat_mod, - nullable=Game._meta.get_field( - "platform" - ).null, - ), - ], - ), - _number( - "Year Min", - "filter-year-min", - year_min, - "e.g. 2020", - ), - _number( - "Year Max", - "filter-year-max", - year_max, - "e.g. 2024", - ), - ], - ), - _range( - "year-range", - "filter-year-min", - "filter-year-max", - year_min, - year_max, - yr_data_min, - yr_data_max, - ), - Component( - tag_name="div", - attributes=[ - ( - "class", - "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", - ), - ], - children=[ - _number( - "Playtime Min (hrs)", - "filter-playtime-min", - playtime_min, - "e.g. 1", - ), - _number( - "Playtime Max (hrs)", - "filter-playtime-max", - playtime_max, - "e.g. 100", - ), - Component( - tag_name="div", - attributes=[("class", "flex items-end pb-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "flex items-center gap-2 text-sm text-heading", - ), - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "checkbox"), - ("name", "filter-mastered"), - ("value", "1"), - *( - [("checked", "true")] - if mastered_val - else [] - ), - ( - "class", - "rounded border-default-medium " - "bg-neutral-secondary-medium text-brand focus:ring-brand", - ), - ], - ), - "Mastered", - ], - ), - ], - ), - ], - ), - _range( - "playtime-range", - "filter-playtime-min", - "filter-playtime-max", - playtime_min or "0", - playtime_max or str(pt_data_max), - 0, - pt_data_max, - ), - Component( - tag_name="div", - attributes=[ - ("class", "flex gap-3 items-center"), - ], - children=[ - Component( - tag_name="button", - attributes=[ - ("type", "submit"), - ( - "class", - "px-4 py-2 text-sm font-medium text-white bg-brand " - "rounded-lg hover:bg-brand-strong focus:ring-4 " - "focus:ring-brand-medium", - ), - ], - children=["Apply"], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ( - "onclick", - f"clearFilterBar('{form_id}', '{filter_input_id}')", - ), - ( - "class", - "px-4 py-2 text-sm font-medium text-gray-900 bg-white " - "border border-gray-200 rounded-lg hover:bg-gray-100 " - "dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 " - "dark:hover:bg-gray-700 dark:hover:text-white", - ), - ], - children=["Clear"], - ), - Component( - tag_name="span", - attributes=[ - ("class", "flex gap-2 items-center"), - ("id", "save-preset-area"), - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "text"), - ("id", "preset-name-input"), - ("placeholder", "Preset name..."), - ( - "class", - "hidden px-3 py-2 text-sm rounded-lg border " - "border-default-medium bg-neutral-secondary-medium " - "text-heading focus:ring-brand focus:border-brand", - ), - ], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ("id", "save-preset-btn"), - ( - "onclick", - "showPresetNameInput()", - ), - ( - "class", - "px-4 py-2 text-sm font-medium text-gray-900 " - "bg-white border border-gray-200 rounded-lg " - "hover:bg-gray-100 dark:bg-gray-800 " - "dark:border-gray-600 dark:text-gray-400 " - "dark:hover:bg-gray-700 dark:hover:text-white", - ), - ], - children=["Save Preset"], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ("id", "confirm-save-preset-btn"), - ( - "onclick", - f"savePreset('{form_id}', '{filter_input_id}', '{preset_save_url}')", - ), - ( - "class", - "hidden px-4 py-2 text-sm font-medium text-white " - "bg-green-700 rounded-lg hover:bg-green-800 " - "focus:ring-4 focus:ring-green-300", - ), - ], - children=["Save"], - ), - ], - ), - Component( - tag_name="div", - attributes=[ - ("id", "preset-dropdown"), - ("class", "relative"), - ("data-preset-list-url", preset_list_url), - ], - children=[ - Component( - tag_name="span", - attributes=[ - ("class", "text-sm text-body"), - ], - children=["Loading presets..."], - ), - ], - ), - ], - ), - ], - ), - ], - ), - ], - ) - - -# ── SelectableFilter widget ──────────────────────────────────────────────── - - -def SelectableFilter( - field_name: str, - options: list[tuple[str, str]], - selected: list[str] | None = None, - excluded: list[str] | None = None, - modifier: str = "", - nullable: bool = True, -) -> "SafeText": - """Stash-style selectable filter with search, include/exclude, modifier tags.""" - selected = selected or [] - excluded = excluded or [] - - active_mod_html = "" - inactive_mod_html = "" - mod_opts = [("NOT_NULL", "(Any)")] - if nullable: - mod_opts.append(("IS_NULL", "(None)")) - for mod_val, mod_label in mod_opts: - if modifier == mod_val: - active_mod_html = ( - f'' - f"{mod_label} " - ) - else: - inactive_mod_html += ( - f'
' - f'{mod_label}
' - ) - - selected_html = "" - for val in selected: - label = _find_label(options, val) - selected_html += ( - f'' - f'\u2713 {escape(label)}' - f' ' - ) - for val in excluded: - label = _find_label(options, val) - selected_html += ( - f'' - f'\u2717 {escape(label)}' - f' ' - ) - - options_html = "" - for val, label in options: - options_html += ( - f'
' - f'{escape(label)}' - f'' - f'' - f'' - f"
" - ) - - return Component( - tag_name="div", - attributes=[ - ( - "class", - "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium", - ), - ("data-selectable-filter", field_name), - *([("data-modifier", modifier)] if modifier else []), - ], - children=[ - Component( - tag_name="div", - attributes=[ - ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"), - ], - children=[mark_safe(active_mod_html + selected_html)], - ), - Component( - tag_name="input", - attributes=[ - ("type", "text"), - ( - "class", - "sf-search block w-full border-0 border-t border-default-medium " - "bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden", - ), - ("placeholder", "Search\u2026"), - ], - ), - Component( - tag_name="div", - attributes=[ - ("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"), - ], - 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 Device, Game, Session - - game_opts = [ - (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name") - ] - dev_opts = [ - (str(k), v) - for k, v in Device.objects.order_by("name").values_list("id", "name") - ] - existing = {} - if filter_json: - try: - import json - - existing = json.loads(filter_json) - except Exception: - pass - - def _gc(f): - raw = existing.get(f, {}) - if not isinstance(raw, dict): - return [], [], "" - v = raw.get("value", []) - e = raw.get("excludes", []) - m = raw.get("modifier", "") - if isinstance(v, str): - v = [v] - if isinstance(e, str): - e = [e] - return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or "" - - gs, ge, gm = _gc("game") - ds, de, dm = _gc("device") - - def _mh(v): - if v is None or v == "" or v == 0: - return "" - try: - m = int(v) - except: - return "" - if m == 0: - return "" - h = m / 60 - return str(int(h)) if h == int(h) else f"{h:.1f}" - - dur = existing.get("duration_minutes", {}) - dmin = _mh(dur.get("value", "")) if isinstance(dur, dict) else "" - dmax = _mh(dur.get("value2", "")) if isinstance(dur, dict) else "" - em = ( - existing.get("emulated", {}).get("value", False) - if isinstance(existing.get("emulated"), dict) - else False - ) - ac = ( - existing.get("is_active", {}).get("value", False) - if isinstance(existing.get("is_active"), dict) - else False - ) - try: - a = Session.objects.aggregate(m=models.Max("duration_total")) - ddm = max( - int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1 - ) - except Exception: - ddm = 200 - - fd, hd = "filter-bar-form", "filter-json-input" - - def _n(l, n, v="", p=""): - return Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=[l], - ), - Component( - tag_name="input", - attributes=[ - ("type", "number"), - ("name", escape(n)), - ("id", escape(n)), - ("value", escape(v)), - ("placeholder", escape(p)), - ( - "class", - "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand", - ), - ], - ), - ], - ) - - def _r(cls, mi, mx, iv, xv, lo, hi, s="1"): - return Component( - tag_name="div", - attributes=[ - ("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), - ("data-min", str(lo)), - ("data-max", str(hi)), - ("data-step", str(s)), - ], - children=[ - mark_safe( - '
' - + f'
' - ), - ], - ) - - return Component( - tag_name="div", - attributes=[("id", "filter-bar"), ("class", "mb-6")], - children=[ - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ( - "onclick", - "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", - ), - ( - "class", - "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2", - ), - ], - children=[ - mark_safe( - '' - ), - "Filters", - ], - ), - Component( - tag_name="div", - attributes=[ - ("id", "filter-bar-body"), - ( - "class", - "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50", - ), - ], - children=[ - Component( - tag_name="form", - attributes=[ - ("id", fd), - ("onsubmit", "return applyFilterBar(event)"), - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "hidden"), - ("id", hd), - ("name", "filter"), - ("value", escape(filter_json)), - ], - ), - Component( - tag_name="div", - attributes=[ - ( - "class", - "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", - ) - ], - children=[ - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=["Game"], - ), - SelectableFilter( - "game", - game_opts, - gs, - ge, - gm, - nullable=not Game._meta.get_field( - "name" - ).has_default(), - ), - ], - ), - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=["Device"], - ), - SelectableFilter( - "device", - dev_opts, - ds, - de, - dm, - nullable=Session._meta.get_field( - "device" - ).null, - ), - ], - ), - _n( - "Duration Min (hrs)", - "filter-playtime-min", - dmin, - "e.g. 0.5", - ), - _n( - "Duration Max (hrs)", - "filter-playtime-max", - dmax, - "e.g. 10", - ), - ], - ), - _r( - "dur-range", - "filter-playtime-min", - "filter-playtime-max", - dmin or "0", - dmax or str(ddm), - 0, - ddm, - ), - Component( - tag_name="div", - attributes=[("class", "flex gap-4 mb-4")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "flex items-center gap-2 text-sm text-heading", - ) - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "checkbox"), - ("name", "filter-emulated"), - ("value", "1"), - *( - [("checked", "true")] - if em - else [] - ), - ( - "class", - "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand", - ), - ], - ), - "Emulated", - ], - ), - Component( - tag_name="label", - attributes=[ - ( - "class", - "flex items-center gap-2 text-sm text-heading", - ) - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "checkbox"), - ("name", "filter-active"), - ("value", "1"), - *( - [("checked", "true")] - if ac - else [] - ), - ( - "class", - "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand", - ), - ], - ), - "Active", - ], - ), - ], - ), - Component( - tag_name="div", - attributes=[("class", "flex gap-3 items-center")], - children=[ - Component( - tag_name="button", - attributes=[ - ("type", "submit"), - ( - "class", - "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", - ), - ], - children=["Apply"], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ( - "onclick", - f"clearFilterBar('{fd}', '{hd}')", - ), - ( - "class", - "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white", - ), - ], - children=["Clear"], - ), - Component( - tag_name="span", - attributes=[ - ("class", "flex gap-2 items-center"), - ("id", "save-preset-area"), - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "text"), - ("id", "preset-name-input"), - ("placeholder", "Preset name..."), - ( - "class", - "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand", - ), - ], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ("id", "save-preset-btn"), - ( - "onclick", - "showPresetNameInput()", - ), - ( - "class", - "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white", - ), - ], - children=["Save Preset"], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ("id", "confirm-save-preset-btn"), - ( - "onclick", - f"savePreset('{fd}', '{hd}', '{preset_save_url}')", - ), - ( - "class", - "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300", - ), - ], - children=["Save"], - ), - ], - ), - Component( - tag_name="div", - attributes=[ - ("id", "preset-dropdown"), - ("class", "relative"), - ("data-preset-list-url", preset_list_url), - ], - children=[ - Component( - tag_name="span", - attributes=[ - ("class", "text-sm text-body") - ], - children=["Loading presets..."], - ) - ], - ), - ], - ), - ], - ), - ], - ), - ], - ) - - -def PurchaseFilterBar(filter_json="", preset_list_url="", preset_save_url=""): - from games.models import Game, Platform, Purchase - - game_opts = [ - (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name") - ] - plat_opts = [ - (str(k), v) - for k, v in Platform.objects.order_by("name").values_list("id", "name") - ] - type_opts = [(t[0], t[1]) for t in Purchase.TYPES] - own_opts = [(t[0], t[1]) for t in Purchase.OWNERSHIP_TYPES] - existing = {} - if filter_json: - try: - import json - - existing = json.loads(filter_json) - except Exception: - pass - - def _gc(f): - raw = existing.get(f, {}) - if not isinstance(raw, dict): - return [], [], "" - v = raw.get("value", []) - e = raw.get("excludes", []) - m = raw.get("modifier", "") - if isinstance(v, str): - v = [v] - if isinstance(e, str): - e = [e] - return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or "" - - gs, ge, gm = _gc("games") - ps, pe, pm = _gc("platform") - ts, te, tm = _gc("type") - os, oe, om = _gc("ownership_type") - price = existing.get("price", {}) - pmin = str(price.get("value", "")) if isinstance(price, dict) else "" - pmax = str(price.get("value2", "")) if isinstance(price, dict) else "" - rf = ( - existing.get("is_refunded", {}).get("value", False) - if isinstance(existing.get("is_refunded"), dict) - else False - ) - try: - a = Purchase.objects.aggregate(lo=models.Min("price"), hi=models.Max("price")) - plo, phi = int(a.get("lo") or 0), max(int(a.get("hi") or 100), 1) - except Exception: - plo, phi = 0, 100 - - fd, hd = "filter-bar-form", "filter-json-input" - - def _n(l, n, v="", p=""): - return Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=[l], - ), - Component( - tag_name="input", - attributes=[ - ("type", "number"), - ("name", escape(n)), - ("id", escape(n)), - ("value", escape(v)), - ("placeholder", escape(p)), - ( - "class", - "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand", - ), - ], - ), - ], - ) - - def _r(cls, mi, mx, iv, xv, lo, hi, s="1"): - return Component( - tag_name="div", - attributes=[ - ("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), - ("data-min", str(lo)), - ("data-max", str(hi)), - ("data-step", str(s)), - ], - children=[ - mark_safe( - '
' - + f'
' - ), - ], - ) - - return Component( - tag_name="div", - attributes=[("id", "filter-bar"), ("class", "mb-6")], - children=[ - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ( - "onclick", - "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", - ), - ( - "class", - "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2", - ), - ], - children=[ - mark_safe( - '' - ), - "Filters", - ], - ), - Component( - tag_name="div", - attributes=[ - ("id", "filter-bar-body"), - ( - "class", - "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50", - ), - ], - children=[ - Component( - tag_name="form", - attributes=[ - ("id", fd), - ("onsubmit", "return applyFilterBar(event)"), - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "hidden"), - ("id", hd), - ("name", "filter"), - ("value", escape(filter_json)), - ], - ), - Component( - tag_name="div", - attributes=[ - ( - "class", - "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", - ) - ], - children=[ - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=["Game"], - ), - SelectableFilter( - "games", - game_opts, - gs, - ge, - gm, - nullable=False, - ), - ], - ), - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=["Platform"], - ), - SelectableFilter( - "platform", - plat_opts, - ps, - pe, - pm, - nullable=Purchase._meta.get_field( - "platform" - ).null, - ), - ], - ), - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=["Type"], - ), - SelectableFilter( - "type", - type_opts, - ts, - te, - tm, - nullable=not Purchase._meta.get_field( - "type" - ).has_default(), - ), - ], - ), - Component( - tag_name="div", - attributes=[("class", "flex flex-col gap-1")], - children=[ - Component( - tag_name="label", - attributes=[ - ( - "class", - "text-xs font-medium text-body uppercase tracking-wide", - ) - ], - children=["Ownership"], - ), - SelectableFilter( - "ownership_type", - own_opts, - os, - oe, - om, - nullable=not Purchase._meta.get_field( - "ownership_type" - ).has_default(), - ), - ], - ), - ], - ), - Component( - tag_name="div", - attributes=[ - ( - "class", - "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4", - ) - ], - children=[ - _n("Price Min", "filter-price-min", pmin, "0.00"), - _n("Price Max", "filter-price-max", pmax, "100.00"), - Component( - tag_name="label", - attributes=[ - ( - "class", - "flex items-center gap-2 text-sm text-heading", - ) - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "checkbox"), - ("name", "filter-refunded"), - ("value", "1"), - *( - [("checked", "true")] - if rf - else [] - ), - ( - "class", - "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand", - ), - ], - ), - "Refunded", - ], - ), - ], - ), - _r( - "price-range", - "filter-price-min", - "filter-price-max", - pmin or str(plo), - pmax or str(phi), - plo, - phi, - ), - Component( - tag_name="div", - attributes=[("class", "flex gap-3 items-center")], - children=[ - Component( - tag_name="button", - attributes=[ - ("type", "submit"), - ( - "class", - "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", - ), - ], - children=["Apply"], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ( - "onclick", - f"clearFilterBar('{fd}', '{hd}')", - ), - ( - "class", - "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white", - ), - ], - children=["Clear"], - ), - Component( - tag_name="span", - attributes=[ - ("class", "flex gap-2 items-center"), - ("id", "save-preset-area"), - ], - children=[ - Component( - tag_name="input", - attributes=[ - ("type", "text"), - ("id", "preset-name-input"), - ("placeholder", "Preset name..."), - ( - "class", - "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand", - ), - ], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ("id", "save-preset-btn"), - ( - "onclick", - "showPresetNameInput()", - ), - ( - "class", - "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white", - ), - ], - children=["Save Preset"], - ), - Component( - tag_name="button", - attributes=[ - ("type", "button"), - ("id", "confirm-save-preset-btn"), - ( - "onclick", - f"savePreset('{fd}', '{hd}', '{preset_save_url}')", - ), - ( - "class", - "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300", - ), - ], - children=["Save"], - ), - ], - ), - Component( - tag_name="div", - attributes=[ - ("id", "preset-dropdown"), - ("class", "relative"), - ("data-preset-list-url", preset_list_url), - ], - children=[ - Component( - tag_name="span", - attributes=[ - ("class", "text-sm text-body") - ], - children=["Loading presets..."], - ) - ], - ), - ], - ), - ], - ), - ], - ), - ], - ) diff --git a/common/components/__init__.py b/common/components/__init__.py new file mode 100644 index 0000000..49ecdbd --- /dev/null +++ b/common/components/__init__.py @@ -0,0 +1,94 @@ +"""Server-side HTML component library. + +Split into core / primitives / domain / filters submodules; this package +re-exports the public API so ``from common.components import X`` keeps working. +""" + +from common.utils import truncate + +from common.components.core import ( + Component, + HTMLAttribute, + HTMLTag, + _render_element, + randomid, +) +from common.components.primitives import ( + A, + AddForm, + Button, + ButtonGroup, + CsrfInput, + Div, + H1, + Icon, + Input, + Modal, + ModuleScript, + Popover, + PopoverTruncated, + SearchField, + SimpleTable, + TableHeader, + TableRow, + TableTd, + paginated_table_content, +) +from common.components.domain import ( + GameLink, + GameStatus, + GameStatusSelector, + LinkedPurchase, + NameWithIcon, + PriceConverted, + PurchasePrice, + SessionDeviceSelector, + _resolve_name_with_icon, +) +from common.components.filters import ( + FilterBar, + PurchaseFilterBar, + SelectableFilter, + SessionFilterBar, +) + +__all__ = [ + "truncate", + "Component", + "HTMLAttribute", + "HTMLTag", + "_render_element", + "randomid", + "A", + "AddForm", + "Button", + "ButtonGroup", + "CsrfInput", + "Div", + "H1", + "Icon", + "Input", + "Modal", + "ModuleScript", + "Popover", + "PopoverTruncated", + "SearchField", + "SimpleTable", + "TableHeader", + "TableRow", + "TableTd", + "paginated_table_content", + "GameLink", + "GameStatus", + "GameStatusSelector", + "LinkedPurchase", + "NameWithIcon", + "PriceConverted", + "PurchasePrice", + "SessionDeviceSelector", + "_resolve_name_with_icon", + "FilterBar", + "PurchaseFilterBar", + "SelectableFilter", + "SessionFilterBar", +] diff --git a/common/components/core.py b/common/components/core.py new file mode 100644 index 0000000..3ec159b --- /dev/null +++ b/common/components/core.py @@ -0,0 +1,74 @@ +"""Escaping core: the Component builder and its memoised renderer.""" + +import hashlib +from functools import lru_cache + +from django.utils.html import escape +from django.utils.safestring import SafeText, mark_safe + + +HTMLAttribute = tuple[str, str | int | bool] + + +HTMLTag = str + + +@lru_cache(maxsize=4096) +def _render_element( + tag_name: str, + attrs_key: tuple[tuple[str, str], ...], + 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 + (attribute values are always escaped). `children_key` is (child, is_safe) + pairs: SafeText children pass through, plain strings are escaped. The + `is_safe` flag is part of the key on purpose — otherwise a safe ``""`` + and an unsafe ``""`` (equal as strings) would collide and one would + render with the wrong escaping. + """ + children_blob = "\n".join( + child if is_safe else escape(child) for child, is_safe in children_key + ) + 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}" + + +def Component( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, + tag_name: str = "", +) -> 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 [] + children = children or [] + if not tag_name: + raise ValueError("tag_name is required.") + if isinstance(children, str): + children = [children] + attrs_key = tuple((name, str(value)) for name, value in attributes) + children_key = tuple((child, isinstance(child, SafeText)) for child in children) + return mark_safe(_render_element(tag_name, attrs_key, children_key)) + + +def randomid(seed: str = "", content: str = "", length: int = 10) -> str: + if not seed and not content: + return seed + hash_input = f"{seed}:{content}" if seed else content + content_hash = hashlib.sha1(hash_input.encode()).hexdigest() + base = ( + content_hash[:length] + if not seed + else content_hash[: max(0, length - len(seed))] + ) + return seed + base diff --git a/common/components/domain.py b/common/components/domain.py new file mode 100644 index 0000000..4df626b --- /dev/null +++ b/common/components/domain.py @@ -0,0 +1,345 @@ +"""Domain components for games / purchases / sessions.""" + +from typing import Any + +from django.template.defaultfilters import floatformat +from django.urls import reverse +from django.utils.safestring import SafeText, mark_safe + +from common.components.core import Component, HTMLTag +from common.components.primitives import ( + A, + Div, + Icon, + Popover, + PopoverTruncated, +) +from games.models import Game, Purchase, Session + + +def GameLink( + game_id: int, + name: str = "", + children: list[HTMLTag] | HTMLTag | None = None, +) -> SafeText: + """Link to a game's detail page. Uses children (slot) if provided, otherwise name.""" + from django.urls import reverse + + children = children or [] + display = children if children else [name] + link = reverse("games:view_game", args=[game_id]) + + return Component( + tag_name="span", + attributes=[("class", "truncate-container")], + children=[ + Component( + tag_name="a", + attributes=[ + ("href", link), + ("class", "underline decoration-slate-500 sm:decoration-2"), + ], + children=display if isinstance(display, list) else [display], + ), + ], + ) + + +_STATUS_COLORS = { + "u": "bg-gray-500", + "p": "bg-orange-400", + "f": "bg-green-500", + "a": "bg-red-500", + "r": "bg-purple-500", +} + + +def GameStatus( + children: list[HTMLTag] | HTMLTag | None = None, + status: str = "u", + display: str = "", + class_: str = "", +) -> SafeText: + """Colored status dot with label. Status codes: u/p/f/a/r.""" + children = children or [] + outer_class = ( + f"{'flex' if display == 'flex' else 'inline-flex'} " + "gap-2 items-center align-middle" + ) + if class_: + outer_class += f" {class_}" + dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"]) + + dot = Component( + tag_name="span", + attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")], + children=["\xa0"], + ) + + return Component( + tag_name="span", + attributes=[("class", outer_class)], + children=[dot] + (children if isinstance(children, list) else [children]), + ) + + +def PriceConverted( + children: list[HTMLTag] | HTMLTag | None = None, +) -> SafeText: + """Wrap content in a span that indicates the price was converted.""" + children = children or [] + return Component( + tag_name="span", + attributes=[ + ("title", "Price is a result of conversion and rounding."), + ("class", "decoration-dotted underline"), + ], + children=children if isinstance(children, list) else [children], + ) + + +def LinkedPurchase(purchase: Purchase) -> SafeText: + link = reverse("games:view_purchase", args=[int(purchase.id)]) + link_content = "" + popover_content = "" + game_count = purchase.games.count() + popover_if_not_truncated = False + if game_count == 1: + link_content += purchase.games.first().name + popover_content = link_content + if game_count > 1: + if purchase.name: + link_content += f"{purchase.name}" + popover_content += f"

{purchase.name}


" + else: + link_content += f"{game_count} games" + popover_if_not_truncated = True + popover_content += f""" +
    + {"".join(f"
  • {game.name}
  • " for game in purchase.games.all())} +
+ """ + icon = ( + (purchase.platform.icon if purchase.platform else "unspecified") + if game_count == 1 + else "unspecified" + ) + if link_content == "": + raise ValueError("link_content is empty!!") + a_content = Div( + [("class", "inline-flex gap-2 items-center")], + [ + Icon( + icon, + [("title", "Multiple")], + ), + PopoverTruncated( + input_string=link_content, + popover_content=mark_safe(popover_content), + popover_if_not_truncated=popover_if_not_truncated, + ), + ], + ) + return A(href=link, children=[a_content]) + + +def NameWithIcon( + name: str = "", + game: Game | None = None, + session: Session | None = None, + linkify: bool = True, + emulated: bool = False, +) -> SafeText: + _name, platform, final_emulated, create_link, link = _resolve_name_with_icon( + name, game, session, linkify + ) + + content = Div( + [("class", "inline-flex gap-2 items-center")], + [ + Icon( + platform.icon, + [("title", platform.name)], + ) + if platform + else "", + Icon("emulated", [("title", "Emulated")]) if final_emulated else "", + PopoverTruncated(_name), + ], + ) + + return ( + A( + href=link, + children=[content], + ) + if create_link + else content + ) + + +def _resolve_name_with_icon( + name: str, + game: Game | None, + session: Session | None, + linkify: bool, +) -> tuple[str, Any, bool, bool, str]: + create_link = False + link = "" + platform = None + final_emulated = False + + if session is not None: + game = session.game + platform = game.platform + final_emulated = session.emulated + if linkify: + create_link = True + link = reverse("games:view_game", args=[int(game.pk)]) + elif game is not None: + platform = game.platform + if linkify: + create_link = True + link = reverse("games:view_game", args=[int(game.pk)]) + + _name = name or (game.name if game else "") + + return _name, platform, final_emulated, create_link, link + + +def PurchasePrice(purchase) -> SafeText: + return Popover( + popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}", + wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}", + wrapped_classes="underline decoration-dotted", + ) + + +def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText: + """Alpine.js dropdown to change a game's status.""" + options_html = "\n".join( + f"" + for value, label in game_statuses + ) + list_items = "\n".join( + f"
  • " + f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}" + f"
  • " + for value, label in game_statuses + ) + + return mark_safe(f""" +
    + {_dropdown_button_html(options_html, list_items)} +
    +""") + + +def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText: + """Alpine.js dropdown to change a session's device.""" + device_id = session.device_id or "null" + device_name = (session.device.name if session.device else "Unknown").replace( + "'", "\\'" + ) + + list_items = "\n".join( + f'
  • {d.name}
  • " + for d in session_devices + ) + + return mark_safe(f""" +
    + { + _dropdown_button_html( + '' + str(Icon("arrowdown")), list_items + ) + } +
    +""") + + +def _dropdown_button_html(button_content: str, list_items: str) -> str: + """Shared dropdown button + list structure for Alpine.js selectors.""" + return ( + '
    ' + '" + "
    " + ) diff --git a/common/components/filters.py b/common/components/filters.py new file mode 100644 index 0000000..b7d9429 --- /dev/null +++ b/common/components/filters.py @@ -0,0 +1,782 @@ +"""Stash-style filter bars and the SelectableFilter widget.""" + +from django.db import models +from django.utils.html import escape +from django.utils.safestring import SafeText, mark_safe + +from common.components.core import Component + + +_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide" + + +_FILTER_INPUT_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" +) + + +_FILTER_CHECKBOX_CLASS = ( + "rounded border-default-medium bg-neutral-secondary-medium " + "text-brand focus:ring-brand" +) + + +_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4" + + +def _filter_parse(filter_json: str) -> dict: + if not filter_json: + return {} + try: + import json + + loaded = json.loads(filter_json) + return loaded if isinstance(loaded, dict) else {} + except (ValueError, TypeError): + return {} + + +def _filter_get_choice(existing: dict, 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 "" + + +def _filter_mins_to_hrs(val) -> str: + 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}" + + +def _filter_field(label: str, widget) -> SafeText: + """A labelled filter field:
    {widget}
    .""" + return Component( + tag_name="div", + attributes=[("class", "flex flex-col gap-1")], + children=[ + Component( + tag_name="label", + attributes=[("class", _FILTER_LABEL_CLASS)], + children=[label], + ), + widget, + ], + ) + + +def _filter_number(label, name, value="", placeholder="") -> SafeText: + return _filter_field( + label, + Component( + tag_name="input", + attributes=[ + ("type", "number"), + ("name", escape(name)), + ("id", escape(name)), + ("value", escape(value)), + ("placeholder", escape(placeholder)), + ("class", _FILTER_INPUT_CLASS), + ], + ), + ) + + +def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: + return Component( + tag_name="label", + attributes=[("class", "flex items-center gap-2 text-sm text-heading")], + children=[ + Component( + tag_name="input", + attributes=[ + ("type", "checkbox"), + ("name", name), + ("value", "1"), + *([("checked", "true")] if checked else []), + ("class", _FILTER_CHECKBOX_CLASS), + ], + ), + label, + ], + ) + + +def _filter_range_inputs(cls, min_id, max_id, min_v, max_v, dmin, dmax, step="1"): + """Twin slider (used by the game filter bar).""" + mv = min_v or str(dmin) + xv = max_v or str(dmax) + return Component( + tag_name="div", + attributes=[("class", f"range-slider {cls} relative h-6 mt-1 mb-2")], + children=[ + mark_safe( + f'' + f'' + ), + ], + ) + + +def _filter_range_handles(cls, min_id, max_id, lo, hi, step="1"): + """Handle-based slider (used by the session & purchase filter bars).""" + 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(step)), + ], + children=[ + mark_safe( + '
    ' + + f'
    ' + ), + ], + ) + + +_FILTER_FORM_ID = "filter-bar-form" + + +_FILTER_INPUT_ID = "filter-json-input" + + +def _filter_collapse_button() -> SafeText: + return Component( + tag_name="button", + attributes=[ + ("type", "button"), + ( + "onclick", + "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", + ), + ( + "class", + "flex items-center gap-2 text-sm font-medium text-body " + "hover:text-heading mb-2", + ), + ], + children=[ + mark_safe( + '' + ), + "Filters", + ], + ) + + +def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: + return 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('{_FILTER_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('{_FILTER_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..."], + ), + ], + ), + ], + ) + + +def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText: + """Shared collapsible filter-bar chrome. `fields` is the per-entity body + (grids, sliders, checkboxes); the shell adds the collapse toggle, the form, + the hidden filter-json input and the Apply/Clear/preset action row.""" + return Component( + tag_name="div", + attributes=[("id", "filter-bar"), ("class", "mb-6")], + children=[ + _filter_collapse_button(), + 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", _FILTER_FORM_ID), + ("onsubmit", "return applyFilterBar(event)"), + ], + children=[ + Component( + tag_name="input", + attributes=[ + ("type", "hidden"), + ("id", _FILTER_INPUT_ID), + ("name", "filter"), + # NB: Component escapes attribute values, so the + # raw JSON is passed through (no double-escape). + ("value", filter_json), + ], + ), + *fields, + _filter_action_row(preset_list_url, preset_save_url), + ], + ), + ], + ), + ], + ) + + +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: + """Collapsible filter bar for the Game list.""" + 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 = _filter_parse(filter_json) + status_sel, status_excl, status_mod = _filter_get_choice(existing, "status") + plat_sel, plat_excl, plat_mod = _filter_get_choice(existing, "platform") + plat_opts_str = [(str(k), v) for k, v in platform_options] + + 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 = ( + _filter_mins_to_hrs(playtime.get("value", "")) + if isinstance(playtime, dict) + else "" + ) + playtime_max = ( + _filter_mins_to_hrs(playtime.get("value2", "")) + if isinstance(playtime, dict) + else "" + ) + + 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 + ) + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Status", + SelectableFilter( + "status", + status_options, + status_sel, + status_excl, + status_mod, + nullable=not Game._meta.get_field("status").has_default(), + ), + ), + _filter_field( + "Platform", + SelectableFilter( + "platform", + plat_opts_str, + plat_sel, + plat_excl, + plat_mod, + nullable=Game._meta.get_field("platform").null, + ), + ), + _filter_number("Year Min", "filter-year-min", year_min, "e.g. 2020"), + _filter_number("Year Max", "filter-year-max", year_max, "e.g. 2024"), + ], + ), + _filter_range_inputs( + "year-range", + "filter-year-min", + "filter-year-max", + year_min, + year_max, + yr_data_min, + yr_data_max, + ), + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_number( + "Playtime Min (hrs)", "filter-playtime-min", playtime_min, "e.g. 1" + ), + _filter_number( + "Playtime Max (hrs)", + "filter-playtime-max", + playtime_max, + "e.g. 100", + ), + Component( + tag_name="div", + attributes=[("class", "flex items-end pb-1")], + children=[ + _filter_checkbox("filter-mastered", "Mastered", mastered_val) + ], + ), + ], + ), + _filter_range_inputs( + "playtime-range", + "filter-playtime-min", + "filter-playtime-max", + playtime_min or "0", + playtime_max or str(pt_data_max), + 0, + pt_data_max, + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def SelectableFilter( + field_name: str, + options: list[tuple[str, str]], + selected: list[str] | None = None, + excluded: list[str] | None = None, + modifier: str = "", + nullable: bool = True, +) -> "SafeText": + """Stash-style selectable filter with search, include/exclude, modifier tags.""" + selected = selected or [] + excluded = excluded or [] + + active_mod_html = "" + inactive_mod_html = "" + mod_opts = [("NOT_NULL", "(Any)")] + if nullable: + mod_opts.append(("IS_NULL", "(None)")) + for mod_val, mod_label in mod_opts: + if modifier == mod_val: + active_mod_html = ( + f'' + f"{mod_label} " + ) + else: + inactive_mod_html += ( + f'
    ' + f'{mod_label}
    ' + ) + + selected_html = "" + for val in selected: + label = _find_label(options, val) + selected_html += ( + f'' + f'\u2713 {escape(label)}' + f' ' + ) + for val in excluded: + label = _find_label(options, val) + selected_html += ( + f'' + f'\u2717 {escape(label)}' + f' ' + ) + + options_html = "" + for val, label in options: + options_html += ( + f'
    ' + f'{escape(label)}' + f'' + f'' + f'' + f"
    " + ) + + return Component( + tag_name="div", + attributes=[ + ( + "class", + "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium", + ), + ("data-selectable-filter", field_name), + *([("data-modifier", modifier)] if modifier else []), + ], + children=[ + Component( + tag_name="div", + attributes=[ + ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"), + ], + children=[mark_safe(active_mod_html + selected_html)], + ), + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ( + "class", + "sf-search block w-full border-0 border-t border-default-medium " + "bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden", + ), + ("placeholder", "Search\u2026"), + ], + ), + Component( + tag_name="div", + attributes=[ + ("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"), + ], + 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="" +) -> SafeText: + """Collapsible filter bar for the Session list.""" + from games.models import Device, Game, 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 = _filter_parse(filter_json) + gs, ge, gm = _filter_get_choice(existing, "game") + ds, de, dm = _filter_get_choice(existing, "device") + + dur = existing.get("duration_minutes", {}) + dmin = _filter_mins_to_hrs(dur.get("value", "")) if isinstance(dur, dict) else "" + dmax = _filter_mins_to_hrs(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 + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Game", + SelectableFilter( + "game", + game_opts, + gs, + ge, + gm, + nullable=not Game._meta.get_field("name").has_default(), + ), + ), + _filter_field( + "Device", + SelectableFilter( + "device", + dev_opts, + ds, + de, + dm, + nullable=Session._meta.get_field("device").null, + ), + ), + _filter_number( + "Duration Min (hrs)", "filter-playtime-min", dmin, "e.g. 0.5" + ), + _filter_number( + "Duration Max (hrs)", "filter-playtime-max", dmax, "e.g. 10" + ), + ], + ), + _filter_range_handles( + "dur-range", "filter-playtime-min", "filter-playtime-max", 0, ddm + ), + Component( + tag_name="div", + attributes=[("class", "flex gap-4 mb-4")], + children=[ + _filter_checkbox("filter-emulated", "Emulated", em), + _filter_checkbox("filter-active", "Active", ac), + ], + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def PurchaseFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the Purchase list.""" + 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 = _filter_parse(filter_json) + gs, ge, gm = _filter_get_choice(existing, "games") + ps, pe, pm = _filter_get_choice(existing, "platform") + ts, te, tm = _filter_get_choice(existing, "type") + os_, oe, om = _filter_get_choice(existing, "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 + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Game", + SelectableFilter("games", game_opts, gs, ge, gm, nullable=False), + ), + _filter_field( + "Platform", + SelectableFilter( + "platform", + plat_opts, + ps, + pe, + pm, + nullable=Purchase._meta.get_field("platform").null, + ), + ), + _filter_field( + "Type", + SelectableFilter( + "type", + type_opts, + ts, + te, + tm, + nullable=not Purchase._meta.get_field("type").has_default(), + ), + ), + _filter_field( + "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", _FILTER_GRID_CLASS)], + children=[ + _filter_number("Price Min", "filter-price-min", pmin, "0.00"), + _filter_number("Price Max", "filter-price-max", pmax, "100.00"), + _filter_checkbox("filter-refunded", "Refunded", rf), + ], + ), + _filter_range_handles( + "price-range", "filter-price-min", "filter-price-max", plo, phi + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) diff --git a/common/components/primitives.py b/common/components/primitives.py new file mode 100644 index 0000000..d50f292 --- /dev/null +++ b/common/components/primitives.py @@ -0,0 +1,784 @@ +"""Generic HTML primitives (no domain knowledge).""" + +from django.middleware.csrf import get_token +from django.templatetags.static import static +from django.urls import reverse +from django.utils.html import conditional_escape +from django.utils.safestring import SafeText, mark_safe + +from common.icons import get_icon +from common.utils import truncate +from common.components.core import Component, HTMLAttribute, HTMLTag, randomid + + +_COLOR_CLASSES = { + "blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", + "red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white", + "gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border", + "green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white", +} + + +_SIZE_CLASSES = { + "xs": "px-3 py-2 text-xs shadow-xs", + "sm": "px-3 py-2 text-sm", + "base": "px-5 py-2.5 text-sm", + "lg": "px-5 py-3 text-base", + "xl": "px-6 py-3.5 text-base", +} + + +def _popover_html( + id: str, + popover_content: str, + wrapped_content: str = "", + wrapped_classes: str = "", + slot: str = "", +) -> SafeText: + """Generate popover HTML using Component(tag_name=...). + + Single source of truth for popover HTML structure. + Used by Popover() and the python_popover template tag bridge. + """ + display_content = wrapped_content if wrapped_content else slot + + span = Component( + tag_name="span", + attributes=[ + ("data-popover-target", id), + ("class", wrapped_classes), + ], + children=[display_content] if display_content else [], + ) + + popover_tooltip_class = ( + "absolute z-10 invisible inline-block text-sm text-white " + "transition-opacity duration-300 bg-white border border-purple-200 " + "rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " + "dark:bg-purple-800" + ) + + div = Component( + tag_name="div", + attributes=[ + ("data-popover", ""), + ("id", id), + ("role", "tooltip"), + ("class", popover_tooltip_class), + ], + children=[ + Component( + tag_name="div", + attributes=[("class", "px-3 py-2")], + children=[popover_content], + ), + Component(tag_name="div", attributes=[("data-popper-arrow", "")]), + mark_safe( # nosec — intentional HTML comment for Tailwind JIT + "" + ), + Component( + tag_name="span", + attributes=[("class", "hidden decoration-dotted")], + ), + ], + ) + + return mark_safe(span + "\n" + div) + + +def Popover( + popover_content: str, + wrapped_content: str = "", + wrapped_classes: str = "", + children: list[HTMLTag] | None = None, + attributes: list[HTMLAttribute] | None = None, + id: str = "", +) -> str: + children = children or [] + if not wrapped_content and not children: + raise ValueError("One of wrapped_content or children is required.") + if not id: + id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") + + slot = mark_safe("\n".join(children)) + return _popover_html( + id=id, + popover_content=popover_content, + wrapped_content=wrapped_content, + wrapped_classes=wrapped_classes, + slot=slot, + ) + + +def PopoverTruncated( + input_string: str, + popover_content: str = "", + popover_if_not_truncated: bool = False, + length: int = 30, + ellipsis: str = "…", + endpart: str = "", +) -> str: + """ + Returns `input_string` truncated after `length` of characters + and displays the untruncated text in a popover HTML element. + The truncated text ends in `ellipsis`, and optionally + an always-visible `endpart` can be specified. + `popover_content` can be specified if: + 1. It needs to be always displayed regardless if text is truncated. + 2. It needs to differ from `input_string`. + """ + if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string: + return Popover( + wrapped_content=truncated, + popover_content=popover_content if popover_content else input_string, + ) + else: + if popover_content and popover_if_not_truncated: + return Popover( + wrapped_content=input_string, + popover_content=popover_content if popover_content else "", + ) + else: + return input_string + + +def A( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, + url_name: str | None = None, + href: str | None = None, +) -> SafeText: + """ + Returns an anchor tag. + + Accepts one of two mutually-exclusive URL specifications: + - url_name: URL pattern name, resolved via reverse() + - href: Literal path string passed through as-is + """ + attributes = attributes or [] + children = children or [] + if url_name is not None and href is not None: + raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") + + additional_attributes = [] + if url_name is not None: + additional_attributes = [("href", reverse(url_name))] + elif href is not None: + additional_attributes = [("href", href)] + return Component( + tag_name="a", attributes=attributes + additional_attributes, children=children + ) + + +def Button( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, + size: str = "base", + icon: bool = False, + color: str = "blue", + type: str = "button", + hx_get: str = "", + hx_target: str = "", + hx_swap: str = "", + title: str = "", + onclick: str = "", + name: str = "", +) -> SafeText: + attributes = attributes or [] + children = children or [] + + # Separate custom class from other generic attributes + custom_class = "" + other_attrs: list[HTMLAttribute] = [] + for attr_name, attr_value in attributes: + if attr_name == "class": + custom_class = str(attr_value) + else: + other_attrs.append((attr_name, attr_value)) + + # Build class string: custom class first, then base, color, size, icon + class_parts: list[str] = [] + if custom_class: + class_parts.append(custom_class) + class_parts.append( + "hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 " + "font-medium mb-2 me-2 rounded-base" + ) + class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"])) + class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"])) + if icon: + class_parts.append("inline-flex text-center items-center gap-2") + + # Build the full attribute list for the button tag + button_attrs: list[HTMLAttribute] = [ + ("type", type), + ("class", " ".join(class_parts)), + ] + if hx_get: + button_attrs.append(("hx-get", hx_get)) + if hx_target: + button_attrs.append(("hx-target", hx_target)) + if hx_swap: + button_attrs.append(("hx-swap", hx_swap)) + if title: + button_attrs.append(("title", title)) + if onclick: + button_attrs.append(("onclick", onclick)) + if name: + button_attrs.append(("name", name)) + button_attrs.extend(other_attrs) + + return Component( + tag_name="button", + attributes=button_attrs, + children=children, + ) + + +_GROUP_BUTTON_COLORS = { + "gray": ( + "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " + "border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 " + "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " + "dark:bg-gray-800 dark:border-gray-700 dark:text-white " + "dark:hover:text-white dark:hover:bg-gray-700 " + "dark:focus:ring-blue-500 dark:focus:text-white" + ), + "red": ( + "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " + "border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 " + "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " + "dark:bg-gray-800 dark:border-gray-700 dark:text-white " + "dark:hover:text-white dark:hover:border-red-700 " + "dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white" + ), + "green": ( + "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " + "border-gray-200 hover:bg-green-500 hover:border-green-600 " + "hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 " + "focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " + "dark:text-white dark:hover:text-white dark:hover:border-green-700 " + "dark:hover:bg-green-600 dark:focus:ring-green-500 " + "dark:focus:text-white" + ), +} + + +def _button_group_button( + href: str, + slot: str, + color: str = "gray", + title: str = "", + hx_get: str = "", + hx_target: str = "", +) -> SafeText: + """Generate a single button-group button (inner @@ -247,20 +247,20 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
  • - Stats + Stats
  • - Log out + Log out
  • @@ -327,7 +327,7 @@ def Page( " \n" f" {scripts}\n" f" {_main_script(mastered)}\n" - ' \n' + " \n" '
    \n' f" {_TOAST_CONTAINER}\n" f' \n' diff --git a/games/api.py b/games/api.py index 0815a35..a6e0440 100644 --- a/games/api.py +++ b/games/api.py @@ -104,7 +104,9 @@ class SessionDeviceUpdate(Schema): @session_router.patch("/{session_id}/device", response={204: None}) -def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate): +def partial_update_session_device( + request, session_id: int, payload: SessionDeviceUpdate +): session = get_object_or_404(Session, id=session_id) session.device_id = payload.device_id session.save() @@ -113,4 +115,3 @@ def partial_update_session_device(request, session_id: int, payload: SessionDevi api.add_router("/session", session_router) - diff --git a/games/filters.py b/games/filters.py index 350ee23..39df191 100644 --- a/games/filters.py +++ b/games/filters.py @@ -13,6 +13,8 @@ from __future__ import annotations from dataclasses import dataclass +from django.db.models import Q + from common.criteria import ( BoolCriterion, ChoiceCriterion, @@ -32,11 +34,11 @@ from common.criteria import ( class FindFilter: """Sorting and pagination, separate from filtering criteria (Stash-style).""" - q: str | None = None # free-text search + 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 + sort: str | None = None # e.g. "-created_at" + direction: str = "desc" # asc / desc # ── GameFilter ───────────────────────────────────────────────────────────── @@ -55,19 +57,17 @@ class GameFilter(OperatorFilter): 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 + 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 + 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 - + def to_q(self) -> Q: q = Q() # ── individual criteria ── @@ -118,7 +118,7 @@ class GameFilter(OperatorFilter): return q @staticmethod - def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported] + def _playtime_to_q(c: IntCriterion) -> Q: """Convert minutes-based criterion to a DurationField Q object. Django stores DurationField as microseconds in SQLite, so we convert @@ -127,16 +127,25 @@ class GameFilter(OperatorFilter): 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)}) + 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)}) + 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: @@ -167,15 +176,15 @@ class SessionFilter(OperatorFilter): OR: SessionFilter | None = None NOT: SessionFilter | None = None - game: MultiCriterion | None = None # filters on game_id - device: MultiCriterion | None = None # filters on device_id + 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 + 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 @@ -184,11 +193,9 @@ class SessionFilter(OperatorFilter): # Cross-entity: sessions for games matching these criteria game_filter: GameFilter | None = None - def to_q(self) -> "Q": # type: ignore[no-any-unimported] + def to_q(self) -> Q: from datetime import timedelta - from django.db.models import Q - q = Q() if self.game is not None: @@ -205,9 +212,19 @@ class SessionFilter(OperatorFilter): 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)}) + 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)}) + 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: @@ -256,6 +273,7 @@ class SessionFilter(OperatorFilter): # 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) @@ -285,17 +303,17 @@ class PurchaseFilter(OperatorFilter): 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 + 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 + 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 @@ -305,9 +323,7 @@ class PurchaseFilter(OperatorFilter): # 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 - + def to_q(self) -> Q: q = Q() if self.name is not None: @@ -353,6 +369,7 @@ class PurchaseFilter(OperatorFilter): # 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) diff --git a/games/forms.py b/games/forms.py index 799527d..0f5323a 100644 --- a/games/forms.py +++ b/games/forms.py @@ -43,7 +43,9 @@ class SessionForm(forms.ModelForm): ), label="Manual duration", ) - device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False) + device = forms.ModelChoiceField( + queryset=Device.objects.order_by("name"), required=False + ) mark_as_played = forms.BooleanField( required=False, diff --git a/games/htmx_middleware.py b/games/htmx_middleware.py index 771e806..be63f23 100644 --- a/games/htmx_middleware.py +++ b/games/htmx_middleware.py @@ -34,9 +34,11 @@ class HTMXMessagesMiddleware: if "HX-Redirect" in response: return response - min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO + min_level = ( + message_constants.DEBUG if settings.DEBUG else message_constants.INFO + ) backend = django_messages.get_messages(request) - if hasattr(backend, '_set_level') and backend._get_level() > min_level: + if hasattr(backend, "_set_level") and backend._get_level() > min_level: backend._set_level(min_level) messages = list(backend) if not messages: diff --git a/games/migrations/0001_initial.py b/games/migrations/0001_initial.py index c5d95d6..1763fe3 100644 --- a/games/migrations/0001_initial.py +++ b/games/migrations/0001_initial.py @@ -6,99 +6,265 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Device', + name="Device", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "type", + models.CharField( + choices=[ + ("PC", "PC"), + ("Console", "Console"), + ("Handheld", "Handheld"), + ("Mobile", "Mobile"), + ("Single-board computer", "Single-board computer"), + ("Unknown", "Unknown"), + ], + default="Unknown", + max_length=255, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), ], ), migrations.CreateModel( - name='Platform', + name="Platform", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('group', models.CharField(blank=True, default=None, max_length=255, null=True)), - ('icon', models.SlugField(blank=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "group", + models.CharField( + blank=True, default=None, max_length=255, null=True + ), + ), + ("icon", models.SlugField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), ], ), migrations.CreateModel( - name='ExchangeRate', + name="ExchangeRate", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('currency_from', models.CharField(max_length=255)), - ('currency_to', models.CharField(max_length=255)), - ('year', models.PositiveIntegerField()), - ('rate', models.FloatField()), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("currency_from", models.CharField(max_length=255)), + ("currency_to", models.CharField(max_length=255)), + ("year", models.PositiveIntegerField()), + ("rate", models.FloatField()), ], options={ - 'unique_together': {('currency_from', 'currency_to', 'year')}, + "unique_together": {("currency_from", "currency_to", "year")}, }, ), migrations.CreateModel( - name='Game', + name="Game", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)), - ('year_released', models.IntegerField(blank=True, default=None, null=True)), - ('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "sort_name", + models.CharField( + blank=True, default=None, max_length=255, null=True + ), + ), + ( + "year_released", + models.IntegerField(blank=True, default=None, null=True), + ), + ( + "wikidata", + models.CharField( + blank=True, default=None, max_length=50, null=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "platform", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="games.platform", + ), + ), ], options={ - 'unique_together': {('name', 'platform', 'year_released')}, + "unique_together": {("name", "platform", "year_released")}, }, ), migrations.CreateModel( - name='Purchase', + name="Purchase", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date_purchased', models.DateField()), - ('date_refunded', models.DateField(blank=True, null=True)), - ('date_finished', models.DateField(blank=True, null=True)), - ('date_dropped', models.DateField(blank=True, null=True)), - ('infinite', models.BooleanField(default=False)), - ('price', models.FloatField(default=0)), - ('price_currency', models.CharField(default='USD', max_length=3)), - ('converted_price', models.FloatField(null=True)), - ('converted_currency', models.CharField(max_length=3, null=True)), - ('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)), - ('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)), - ('name', models.CharField(blank=True, default='', max_length=255, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')), - ('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')), - ('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date_purchased", models.DateField()), + ("date_refunded", models.DateField(blank=True, null=True)), + ("date_finished", models.DateField(blank=True, null=True)), + ("date_dropped", models.DateField(blank=True, null=True)), + ("infinite", models.BooleanField(default=False)), + ("price", models.FloatField(default=0)), + ("price_currency", models.CharField(default="USD", max_length=3)), + ("converted_price", models.FloatField(null=True)), + ("converted_currency", models.CharField(max_length=3, null=True)), + ( + "ownership_type", + models.CharField( + choices=[ + ("ph", "Physical"), + ("di", "Digital"), + ("du", "Digital Upgrade"), + ("re", "Rented"), + ("bo", "Borrowed"), + ("tr", "Trial"), + ("de", "Demo"), + ("pi", "Pirated"), + ], + default="di", + max_length=2, + ), + ), + ( + "type", + models.CharField( + choices=[ + ("game", "Game"), + ("dlc", "DLC"), + ("season_pass", "Season Pass"), + ("battle_pass", "Battle Pass"), + ], + default="game", + max_length=255, + ), + ), + ( + "name", + models.CharField(blank=True, default="", max_length=255, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "games", + models.ManyToManyField( + blank=True, related_name="purchases", to="games.game" + ), + ), + ( + "platform", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="games.platform", + ), + ), + ( + "related_purchase", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="related_purchases", + to="games.purchase", + ), + ), ], ), migrations.CreateModel( - name='Session', + name="Session", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('timestamp_start', models.DateTimeField()), - ('timestamp_end', models.DateTimeField(blank=True, null=True)), - ('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)), - ('duration_calculated', models.DurationField(blank=True, null=True)), - ('note', models.TextField(blank=True, null=True)), - ('emulated', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')), - ('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timestamp_start", models.DateTimeField()), + ("timestamp_end", models.DateTimeField(blank=True, null=True)), + ( + "duration_manual", + models.DurationField( + blank=True, default=datetime.timedelta(0), null=True + ), + ), + ("duration_calculated", models.DurationField(blank=True, null=True)), + ("note", models.TextField(blank=True, null=True)), + ("emulated", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "device", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="games.device", + ), + ), + ( + "game", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sessions", + to="games.game", + ), + ), ], options={ - 'get_latest_by': 'timestamp_start', + "get_latest_by": "timestamp_start", }, ), ] diff --git a/games/migrations/0002_purchase_price_per_game.py b/games/migrations/0002_purchase_price_per_game.py index efeb68a..f3f5824 100644 --- a/games/migrations/0002_purchase_price_per_game.py +++ b/games/migrations/0002_purchase_price_per_game.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0001_initial'), + ("games", "0001_initial"), ] operations = [ migrations.AddField( - model_name='purchase', - name='price_per_game', + model_name="purchase", + name="price_per_game", field=models.FloatField(null=True), ), ] diff --git a/games/migrations/0003_purchase_updated_at.py b/games/migrations/0003_purchase_updated_at.py index 720ea6f..de3d6d0 100644 --- a/games/migrations/0003_purchase_updated_at.py +++ b/games/migrations/0003_purchase_updated_at.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0002_purchase_price_per_game'), + ("games", "0002_purchase_price_per_game"), ] operations = [ migrations.AddField( - model_name='purchase', - name='updated_at', + model_name="purchase", + name="updated_at", field=models.DateTimeField(auto_now=True), ), ] diff --git a/games/migrations/0006_alter_game_sort_name_alter_game_wikidata_and_more.py b/games/migrations/0006_alter_game_sort_name_alter_game_wikidata_and_more.py index 1a5cc0f..e3254a3 100644 --- a/games/migrations/0006_alter_game_sort_name_alter_game_wikidata_and_more.py +++ b/games/migrations/0006_alter_game_sort_name_alter_game_wikidata_and_more.py @@ -5,55 +5,66 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0005_game_mastered_game_status'), + ("games", "0005_game_mastered_game_status"), ] operations = [ migrations.AlterField( - model_name='game', - name='sort_name', - field=models.CharField(blank=True, default='', max_length=255), + model_name="game", + name="sort_name", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AlterField( - model_name='game', - name='wikidata', - field=models.CharField(blank=True, default='', max_length=50), + model_name="game", + name="wikidata", + field=models.CharField(blank=True, default="", max_length=50), ), migrations.AlterField( - model_name='platform', - name='group', - field=models.CharField(blank=True, default='', max_length=255), + model_name="platform", + name="group", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AlterField( - model_name='purchase', - name='converted_currency', - field=models.CharField(blank=True, default='', max_length=3), + model_name="purchase", + name="converted_currency", + field=models.CharField(blank=True, default="", max_length=3), ), migrations.AlterField( - model_name='purchase', - name='games', - field=models.ManyToManyField(related_name='purchases', to='games.game'), + model_name="purchase", + name="games", + field=models.ManyToManyField(related_name="purchases", to="games.game"), ), migrations.AlterField( - model_name='purchase', - name='name', - field=models.CharField(blank=True, default='', max_length=255), + model_name="purchase", + name="name", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AlterField( - model_name='purchase', - name='related_purchase', - field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'), + model_name="purchase", + name="related_purchase", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="related_purchases", + to="games.purchase", + ), ), migrations.AlterField( - model_name='session', - name='game', - field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'), + model_name="session", + name="game", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sessions", + to="games.game", + ), ), migrations.AlterField( - model_name='session', - name='note', - field=models.TextField(blank=True, default=''), + model_name="session", + name="note", + field=models.TextField(blank=True, default=""), ), ] diff --git a/games/migrations/0007_game_updated_at.py b/games/migrations/0007_game_updated_at.py index 6fbce47..400b19d 100644 --- a/games/migrations/0007_game_updated_at.py +++ b/games/migrations/0007_game_updated_at.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'), + ("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"), ] operations = [ migrations.AddField( - model_name='game', - name='updated_at', + model_name="game", + name="updated_at", field=models.DateTimeField(auto_now=True), ), ] diff --git a/games/migrations/0009_remove_purchase_date_dropped_and_more.py b/games/migrations/0009_remove_purchase_date_dropped_and_more.py index 4ee2d17..57955c4 100644 --- a/games/migrations/0009_remove_purchase_date_dropped_and_more.py +++ b/games/migrations/0009_remove_purchase_date_dropped_and_more.py @@ -4,18 +4,17 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('games', '0008_game_original_year_released_gamestatuschange_and_more'), + ("games", "0008_game_original_year_released_gamestatuschange_and_more"), ] operations = [ migrations.RemoveField( - model_name='purchase', - name='date_dropped', + model_name="purchase", + name="date_dropped", ), migrations.RemoveField( - model_name='purchase', - name='date_finished', + model_name="purchase", + name="date_finished", ), ] diff --git a/games/migrations/0010_remove_purchase_price_per_game.py b/games/migrations/0010_remove_purchase_price_per_game.py index 5befafc..d55a3bb 100644 --- a/games/migrations/0010_remove_purchase_price_per_game.py +++ b/games/migrations/0010_remove_purchase_price_per_game.py @@ -4,14 +4,13 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('games', '0009_remove_purchase_date_dropped_and_more'), + ("games", "0009_remove_purchase_date_dropped_and_more"), ] operations = [ migrations.RemoveField( - model_name='purchase', - name='price_per_game', + model_name="purchase", + name="price_per_game", ), ] diff --git a/games/migrations/0011_purchase_price_per_game.py b/games/migrations/0011_purchase_price_per_game.py index ab9e6f2..3fff41c 100644 --- a/games/migrations/0011_purchase_price_per_game.py +++ b/games/migrations/0011_purchase_price_per_game.py @@ -6,15 +6,24 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0010_remove_purchase_price_per_game'), + ("games", "0010_remove_purchase_price_per_game"), ] operations = [ migrations.AddField( - model_name='purchase', - name='price_per_game', - field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()), + model_name="purchase", + name="price_per_game", + field=models.GeneratedField( + db_persist=True, + expression=django.db.models.expressions.CombinedExpression( + django.db.models.functions.comparison.Coalesce( + models.F("converted_price"), models.F("price"), 0 + ), + "/", + models.F("num_purchases"), + ), + output_field=models.FloatField(), + ), ), ] diff --git a/games/migrations/0014_session_duration_total.py b/games/migrations/0014_session_duration_total.py index 6044feb..e0c1b41 100644 --- a/games/migrations/0014_session_duration_total.py +++ b/games/migrations/0014_session_duration_total.py @@ -5,15 +5,20 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0013_game_playtime'), + ("games", "0013_game_playtime"), ] operations = [ migrations.AddField( - model_name='session', - name='duration_total', - field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()), + model_name="session", + name="duration_total", + field=models.GeneratedField( + db_persist=True, + expression=django.db.models.expressions.CombinedExpression( + models.F("duration_calculated"), "+", models.F("duration_manual") + ), + output_field=models.DurationField(), + ), ), ] diff --git a/games/migrations/0015_alter_purchase_date_purchased_and_more.py b/games/migrations/0015_alter_purchase_date_purchased_and_more.py index 494bf88..931cb33 100644 --- a/games/migrations/0015_alter_purchase_date_purchased_and_more.py +++ b/games/migrations/0015_alter_purchase_date_purchased_and_more.py @@ -5,35 +5,39 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0014_session_duration_total'), + ("games", "0014_session_duration_total"), ] operations = [ migrations.AlterField( - model_name='purchase', - name='date_purchased', - field=models.DateField(verbose_name='Purchased'), + model_name="purchase", + name="date_purchased", + field=models.DateField(verbose_name="Purchased"), ), migrations.AlterField( - model_name='purchase', - name='date_refunded', - field=models.DateField(blank=True, null=True, verbose_name='Refunded'), + model_name="purchase", + name="date_refunded", + field=models.DateField(blank=True, null=True, verbose_name="Refunded"), ), migrations.AlterField( - model_name='session', - name='duration_manual', - field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'), + model_name="session", + name="duration_manual", + field=models.DurationField( + blank=True, + default=datetime.timedelta(0), + null=True, + verbose_name="Manual duration", + ), ), migrations.AlterField( - model_name='session', - name='timestamp_end', - field=models.DateTimeField(blank=True, null=True, verbose_name='End'), + model_name="session", + name="timestamp_end", + field=models.DateTimeField(blank=True, null=True, verbose_name="End"), ), migrations.AlterField( - model_name='session', - name='timestamp_start', - field=models.DateTimeField(verbose_name='Start'), + model_name="session", + name="timestamp_start", + field=models.DateTimeField(verbose_name="Start"), ), ] diff --git a/games/migrations/0016_add_needs_price_update.py b/games/migrations/0016_add_needs_price_update.py index 83f06fa..64c092d 100644 --- a/games/migrations/0016_add_needs_price_update.py +++ b/games/migrations/0016_add_needs_price_update.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0015_alter_purchase_date_purchased_and_more'), + ("games", "0015_alter_purchase_date_purchased_and_more"), ] operations = [ migrations.AddField( - model_name='purchase', - name='needs_price_update', + model_name="purchase", + name="needs_price_update", field=models.BooleanField(db_index=True, default=True), ), migrations.RunSQL( diff --git a/games/migrations/0017_add_filter_preset.py b/games/migrations/0017_add_filter_preset.py index e7064e8..d641740 100644 --- a/games/migrations/0017_add_filter_preset.py +++ b/games/migrations/0017_add_filter_preset.py @@ -4,26 +4,45 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0016_add_needs_price_update'), + ("games", "0016_add_needs_price_update"), ] operations = [ migrations.CreateModel( - name='FilterPreset', + 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)), + ( + "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'], + "ordering": ["name"], }, ), ] diff --git a/games/models.py b/games/models.py index c4ecf5c..7cae0b5 100644 --- a/games/models.py +++ b/games/models.py @@ -66,8 +66,10 @@ class Game(models.Model): return self.name def finished(self): - return (self.status == self.Status.FINISHED or - self.playevents.filter(ended__isnull=False).exists()) + return ( + self.status == self.Status.FINISHED + or self.playevents.filter(ended__isnull=False).exists() + ) def abandoned(self): return self.status == self.Status.ABANDONED diff --git a/games/tasks.py b/games/tasks.py index bb113b5..e4920ff 100644 --- a/games/tasks.py +++ b/games/tasks.py @@ -60,7 +60,9 @@ def _save_converted_price(purchase, converted_price, needs_update): purchase.converted_currency = currency_to if needs_update: purchase.needs_price_update = False - purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"]) + purchase.save( + update_fields=["converted_price", "converted_currency", "needs_price_update"] + ) def convert_prices(): diff --git a/games/templatetags/randomid.py b/games/templatetags/randomid.py index 8b571cb..4691323 100644 --- a/games/templatetags/randomid.py +++ b/games/templatetags/randomid.py @@ -9,5 +9,5 @@ register = template.Library() def randomid(seed: str = "") -> str: content_hash = hashlib.sha1(seed.encode()).hexdigest() if seed: - return content_hash[:max(0, 10 - len(seed))] + seed + return content_hash[: max(0, 10 - len(seed))] + seed return content_hash[:10] diff --git a/games/urls.py b/games/urls.py index c8e4eb8..c9e8ada 100644 --- a/games/urls.py +++ b/games/urls.py @@ -23,7 +23,11 @@ urlpatterns = [ path("game/add", game.add_game, name="add_game"), path("game//edit", game.edit_game, name="edit_game"), path("game//view", game.view_game, name="view_game"), - path("game//delete/confirm", game.delete_game_confirmation, name="delete_game_confirmation"), + path( + "game//delete/confirm", + game.delete_game_confirmation, + name="delete_game_confirmation", + ), path("game//delete", game.delete_game, name="delete_game"), path("game/list", game.list_games, name="list_games"), path("platform/add", platform.add_platform, name="add_platform"), @@ -175,4 +179,4 @@ urlpatterns = [ filter_presets.load_preset, name="load_preset", ), -] \ No newline at end of file +] diff --git a/games/views/filter_presets.py b/games/views/filter_presets.py index 7781679..70d54b5 100644 --- a/games/views/filter_presets.py +++ b/games/views/filter_presets.py @@ -5,10 +5,10 @@ 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.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse -from django.utils.safestring import SafeText, mark_safe +from django.utils.safestring import mark_safe from games.models import FilterPreset @@ -21,9 +21,7 @@ def list_presets(request: HttpRequest) -> HttpResponse: items: list[str] = [] for preset in presets: - filter_json = ( - json.dumps(preset.object_filter) if preset.object_filter else "" - ) + 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]) @@ -40,14 +38,9 @@ def list_presets(request: HttpRequest) -> HttpResponse: ) if not items: - items = [ - '
  • ' - "No saved presets
  • " - ] + items = ['
  • No saved presets
  • '] - return HttpResponse( - mark_safe(f'
      {"".join(items)}
    ') - ) + return HttpResponse(mark_safe(f'
      {"".join(items)}
    ')) @login_required diff --git a/games/views/game.py b/games/views/game.py index c6a0f90..fde70ee 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -148,7 +148,9 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse: request, content, title="Manage games", - scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"), + scripts=ModuleScript("range_slider.js") + + ModuleScript("selectable_filter.js") + + ModuleScript("filter_bar.js"), ) @@ -540,159 +542,34 @@ def _game_section( ) -@login_required -def view_game(request: HttpRequest, game_id: int) -> HttpResponse: - game = Game.objects.get(id=game_id) - purchases = game.purchases.order_by("date_purchased") - +def _game_overview_metrics(game: Game) -> dict[str, Any]: + """Request-free header metrics: total session count, play range, and the + per-session average (excluding manually-logged sessions).""" sessions = game.sessions session_count = sessions.count() - session_count_without_manual = game.sessions.without_manual().count() + session_count_without_manual = sessions.without_manual().count() if sessions.exists(): - playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y") - latest_session = sessions.latest() - playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y") - - playrange = ( - playrange_start - if playrange_start == playrange_end - else f"{playrange_start} — {playrange_end}" - ) + start = local_strftime(sessions.earliest().timestamp_start, "%b %Y") + end = local_strftime(sessions.latest().timestamp_start, "%b %Y") + playrange = start if start == end else f"{start} — {end}" else: playrange = "N/A" - latest_session = None total_hours_without_manual = float( format_duration(sessions.calculated_duration_unformatted(), "%2.1H") ) - - purchase_data: dict[str, Any] = { - "columns": ["Name", "Type", "Date", "Price", "Actions"], - "rows": [ - [ - LinkedPurchase(purchase), - purchase.get_type_display(), - purchase.date_purchased.strftime(dateformat), - PurchasePrice(purchase), - ButtonGroup( - [ - { - "href": reverse("games:edit_purchase", args=[purchase.pk]), - "slot": Icon("edit"), - "color": "gray", - }, - { - "href": reverse( - "games:delete_purchase", args=[purchase.pk] - ), - "slot": Icon("delete"), - "color": "red", - }, - ] - ), - ] - for purchase in purchases - ], - } - - sessions_all = game.sessions.order_by("-timestamp_start") - - last_session = None - if sessions_all.exists(): - last_session = sessions_all.latest() - session_count = sessions_all.count() - session_paginator = Paginator(sessions_all, 5) - page_number = request.GET.get("page", 1) - session_page_obj = session_paginator.get_page(page_number) - sessions = session_page_obj.object_list - - session_data: dict[str, Any] = { - "header_action": Div( - children=[ - A( - url_name="games:add_session", - children=Button( - icon=True, - size="xs", - children=[Icon("play"), "LOG"], - ), - ), - A( - href=reverse( - "games:list_sessions_start_session_from_session", - args=[last_session.pk], - ), - children=Popover( - popover_content=last_session.game.name, - children=[ - Button( - icon=True, - color="gray", - size="xs", - children=[ - Icon("play"), - truncate(f"{last_session.game.name}"), - ], - ) - ], - ), - ) - if last_session - else "", - ], - ), - "columns": ["Game", "Date", "Duration", "Actions"], - "rows": [ - [ - NameWithIcon(session=session), - f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", - session.duration_formatted_with_mark(), - ButtonGroup( - [ - { - "href": reverse( - "games:list_sessions_end_session", args=[session.pk] - ), - "slot": Icon("end"), - "title": "Finish session now", - "color": "green", - } - if session.timestamp_end is None - else {}, - { - "href": reverse("games:edit_session", args=[session.pk]), - "slot": Icon("edit"), - "color": "gray", - }, - { - "href": reverse("games:delete_session", args=[session.pk]), - "slot": Icon("delete"), - "color": "red", - }, - ] - ), - ] - for session in sessions - ], - } - - playevents = game.playevents.all() - playevent_count = playevents.count() - playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"]) - - statuschanges = game.status_changes.all() - statuschange_count = statuschanges.count() - - purchase_count = game.purchases.count() - status_selector_html = GameStatusSelector( - game, Game.Status.choices, get_token(request) - ) session_average_without_manual = round( - safe_division(total_hours_without_manual, int(session_count_without_manual)), - 1, + safe_division(total_hours_without_manual, int(session_count_without_manual)), 1 ) + return { + "session_count": session_count, + "playrange": playrange, + "session_average_without_manual": session_average_without_manual, + } + +def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText: grey_value_class = "text-black dark:text-slate-300" title_span = Component( tag_name="span", @@ -718,8 +595,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: else [] ), ) - title_row = Div([("class", "flex gap-5 mb-3")], [title_span]) - stats_row = Div( [("class", "flex gap-4 dark:text-slate-400 mb-3")], [ @@ -730,23 +605,25 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: game.playtime_formatted(), ), _stat_popover( - "popover-sessions", "Number of sessions", "sessions", session_count + "popover-sessions", + "Number of sessions", + "sessions", + metrics["session_count"], ), _stat_popover( "popover-average", "Average playtime per session", "average", - session_average_without_manual, + metrics["session_average_without_manual"], ), _stat_popover( "popover-playrange", "Earliest and latest dates played", "playrange", - playrange, + metrics["playrange"], ), ], ) - metadata = Div( [("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")], [ @@ -758,7 +635,11 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: children=[str(game.original_year_released)], ), ), - _meta_row("Status", status_selector_html, "👑" if game.mastered else ""), + _meta_row( + "Status", + GameStatusSelector(game, Game.Status.choices, get_token(request)), + "👑" if game.mastered else "", + ), _played_row(game, request), _meta_row( "Platform", @@ -770,36 +651,144 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: ), ], ) - - game_info = Div( + return Div( [("id", "game-info"), ("class", "mb-10")], - [title_row, stats_row, metadata, _game_action_buttons(game)], + [ + Div([("class", "flex gap-5 mb-3")], [title_span]), + stats_row, + metadata, + _game_action_buttons(game), + ], ) - session_elided_page_range = ( - session_page_obj.paginator.get_elided_page_range( - page_number, on_each_side=1, on_ends=1 - ) - if session_page_obj and session_count > 5 + +def _purchases_section(game: Game) -> SafeText: + purchases = game.purchases.order_by("date_purchased") + rows = [ + [ + LinkedPurchase(purchase), + purchase.get_type_display(), + purchase.date_purchased.strftime(dateformat), + PurchasePrice(purchase), + ButtonGroup( + [ + { + "href": reverse("games:edit_purchase", args=[purchase.pk]), + "slot": Icon("edit"), + "color": "gray", + }, + { + "href": reverse("games:delete_purchase", args=[purchase.pk]), + "slot": Icon("delete"), + "color": "red", + }, + ] + ), + ] + for purchase in purchases + ] + table = SimpleTable(columns=["Name", "Type", "Date", "Price", "Actions"], rows=rows) + return _game_section("Purchases", purchases.count(), table, "No purchases yet.") + + +def _sessions_section(game: Game, request: HttpRequest) -> SafeText: + sessions_all = game.sessions.order_by("-timestamp_start") + session_count = sessions_all.count() + last_session = sessions_all.latest() if sessions_all.exists() else None + + page_number = request.GET.get("page", 1) + page_obj = Paginator(sessions_all, 5).get_page(page_number) + elided_page_range = ( + page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1) + if session_count > 5 else None ) - purchases_table = SimpleTable( - columns=purchase_data["columns"], rows=purchase_data["rows"] + header_action = Div( + children=[ + A( + url_name="games:add_session", + children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]), + ), + A( + href=reverse( + "games:list_sessions_start_session_from_session", + args=[last_session.pk], + ), + children=Popover( + popover_content=last_session.game.name, + children=[ + Button( + icon=True, + color="gray", + size="xs", + children=[ + Icon("play"), + truncate(f"{last_session.game.name}"), + ], + ) + ], + ), + ) + if last_session + else "", + ], ) - sessions_table = SimpleTable( - columns=session_data["columns"], - rows=session_data["rows"], - header_action=session_data["header_action"], - page_obj=session_page_obj, - elided_page_range=session_elided_page_range, + rows = [ + [ + NameWithIcon(session=session), + f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", + session.duration_formatted_with_mark(), + ButtonGroup( + [ + { + "href": reverse( + "games:list_sessions_end_session", args=[session.pk] + ), + "slot": Icon("end"), + "title": "Finish session now", + "color": "green", + } + if session.timestamp_end is None + else {}, + { + "href": reverse("games:edit_session", args=[session.pk]), + "slot": Icon("edit"), + "color": "gray", + }, + { + "href": reverse("games:delete_session", args=[session.pk]), + "slot": Icon("delete"), + "color": "red", + }, + ] + ), + ] + for session in page_obj.object_list + ] + table = SimpleTable( + columns=["Game", "Date", "Duration", "Actions"], + rows=rows, + header_action=header_action, + page_obj=page_obj, + elided_page_range=elided_page_range, request=request, ) - playevents_table = SimpleTable( - columns=playevent_data["columns"], rows=playevent_data["rows"] + return _game_section("Sessions", session_count, table, "No sessions yet.") + + +def _playevents_section(game: Game) -> SafeText: + playevents = game.playevents.all() + data = create_playevent_tabledata(playevents, exclude_columns=["Game"]) + table = SimpleTable(columns=data["columns"], rows=data["rows"]) + return _game_section( + "Play Events", playevents.count(), table, "No play events yet." ) - history = Div( + +def _history_section(game: Game) -> SafeText: + statuschanges = game.status_changes.all() + return Div( [ ("class", "mb-6"), ("id", "history-container"), @@ -809,36 +798,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: ("hx-swap", "outerHTML"), ], [ - H1(children=["History"], badge=statuschange_count), + H1(children=["History"], badge=statuschanges.count()), _game_history(statuschanges), ], ) + +_GET_SESSION_COUNT_SCRIPT = mark_safe( + "" +) + + +@login_required +def view_game(request: HttpRequest, game_id: int) -> HttpResponse: + game = Game.objects.get(id=game_id) content = Div( [("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")], [ - game_info, - _game_section( - "Purchases", purchase_count, purchases_table, "No purchases yet." - ), - _game_section( - "Sessions", session_count, sessions_table, "No sessions yet." - ), - _game_section( - "Play Events", playevent_count, playevents_table, "No play events yet." - ), - history, - mark_safe( - "" - ), + _game_header(game, request, _game_overview_metrics(game)), + _purchases_section(game), + _sessions_section(game, request), + _playevents_section(game), + _history_section(game), + _GET_SESSION_COUNT_SCRIPT, ], ) - request.session["return_path"] = request.path return render_page( request, diff --git a/games/views/purchase.py b/games/views/purchase.py index b699b14..6dda8d1 100644 --- a/games/views/purchase.py +++ b/games/views/purchase.py @@ -100,6 +100,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse: filter_json = request.GET.get("filter", "") if filter_json: from games.filters import parse_purchase_filter + pf = parse_purchase_filter(filter_json) if pf is not None: purchases = purchases.filter(pf.to_q()) @@ -129,6 +130,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse: request=request, ) from common.components import PurchaseFilterBar, ModuleScript + filter_bar = PurchaseFilterBar( filter_json=filter_json, preset_list_url=reverse("games:list_presets"), @@ -139,7 +141,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse: request, content, title="Manage purchases", - scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"), + scripts=ModuleScript("range_slider.js") + + ModuleScript("selectable_filter.js") + + ModuleScript("filter_bar.js"), ) diff --git a/games/views/session.py b/games/views/session.py index f54e8cd..539c755 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -45,6 +45,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse filter_json = request.GET.get("filter", "") if filter_json: from games.filters import parse_session_filter + session_filter = parse_session_filter(filter_json) if session_filter is not None: sessions = sessions.filter(session_filter.to_q()) @@ -168,6 +169,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse request=request, ) from common.components import SessionFilterBar + filter_json = request.GET.get("filter", "") filter_bar = SessionFilterBar( filter_json=filter_json, @@ -179,7 +181,9 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse request, content, title="Manage sessions", - scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"), + scripts=ModuleScript("range_slider.js") + + ModuleScript("selectable_filter.js") + + ModuleScript("filter_bar.js"), ) diff --git a/games/views/stats_data.py b/games/views/stats_data.py index 4a408f8..6c635f9 100644 --- a/games/views/stats_data.py +++ b/games/views/stats_data.py @@ -176,7 +176,9 @@ def compute_stats(year: int | None = None) -> StatsData: unique_days_percent = int(unique_days / 365 * 100) # ── Spending ───────────────────────────────────────────────────────────── - total_spent = without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0 + total_spent = ( + without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0 + ) without_refunded_count = without_refunded.count() # ── Purchase breakdown ─────────────────────────────────────────────────── @@ -185,7 +187,10 @@ def compute_stats(year: int | None = None) -> StatsData: without_refunded.filter(not_finished_q) .filter(infinite=False) .filter(only_games_and_dlc) - .filter(~Q(games__status=Game.Status.RETIRED) & ~Q(games__status=Game.Status.ABANDONED)) + .filter( + ~Q(games__status=Game.Status.RETIRED) + & ~Q(games__status=Game.Status.ABANDONED) + ) ) dropped = ( purchases.filter(not_finished_q) @@ -270,9 +275,7 @@ def compute_stats(year: int | None = None) -> StatsData: data: StatsData = { "year": year_label, "title": f"{year_label} Stats", - "total_hours": format_duration( - sessions.total_duration_unformatted(), "%2.0H" - ), + "total_hours": format_duration(sessions.total_duration_unformatted(), "%2.0H"), "total_sessions": sessions.count(), "unique_days": unique_days, "unique_days_percent": unique_days_percent, diff --git a/tests/test_components.py b/tests/test_components.py index 48df1bf..81f2923 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -36,9 +36,7 @@ class ComponentCacheTest(unittest.TestCase): self.assertGreaterEqual(info.hits, 1) # served from cache def test_cache_is_bounded(self): - self.assertEqual( - components._render_element.cache_parameters()["maxsize"], 4096 - ) + self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096) def test_safe_and_unsafe_children_do_not_collide(self): """A SafeText "" and a plain "" are equal as strings but must @@ -207,7 +205,9 @@ class ComponentReturnTypeTest(unittest.TestCase): def test_a_url_name_reversed(self): from unittest.mock import patch - with patch("common.components.reverse", return_value="/resolved/url"): + with patch( + "common.components.primitives.reverse", return_value="/resolved/url" + ): result = components.A([], "link", url_name="some_name") self.assertIn('href="/resolved/url"', result) @@ -666,7 +666,7 @@ class ResolveNameWithIconTest(unittest.TestCase): override_game.name = "Override" override_game.platform = self.mock_platform override_game.pk = 99 - with patch("common.components.reverse", return_value="/game/99"): + with patch("common.components.domain.reverse", return_value="/game/99"): name, platform, emulated, create_link, link = ( components._resolve_name_with_icon( "", override_game, self.mock_session, True @@ -676,7 +676,7 @@ class ResolveNameWithIconTest(unittest.TestCase): self.assertIsNot(name, "Override") def test_game_only_provides_platform(self): - with patch("common.components.reverse", return_value="/game/1"): + with patch("common.components.domain.reverse", return_value="/game/1"): name, platform, emulated, create_link, link = ( components._resolve_name_with_icon("", self.mock_game, None, True) ) @@ -713,7 +713,7 @@ class ResolveNameWithIconTest(unittest.TestCase): self.assertEqual(link, "") def test_linkify_true_creates_link(self): - with patch("common.components.reverse", return_value="/game/42"): + with patch("common.components.domain.reverse", return_value="/game/42"): name, platform, emulated, create_link, link = ( components._resolve_name_with_icon("", self.mock_game, None, True) ) diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py new file mode 100644 index 0000000..8578827 --- /dev/null +++ b/tests/test_filter_bars.py @@ -0,0 +1,109 @@ +"""Characterization tests locking the rendered output of the three filter bars. + +The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the +target of an upcoming dedup + module split. These tests pin the structural +contract — form/input ids, the hidden ``filter`` field, preset wiring, the +filter_json round-trip, and no double-escaping — so that refactor stays +behaviour-preserving. The renderers were previously untested. +""" + +import json + +from django.test import TestCase + +from common.components import ( + FilterBar, + PurchaseFilterBar, + SelectableFilter, + SessionFilterBar, +) +from games.models import Device, Game, Platform + +_ESCAPED_TAG_MARKERS = ["<div", "<span", "<button", "<input", "<a"] + + +class FilterBarRenderingTest(TestCase): + def setUp(self): + self.platform = Platform.objects.create(name="PC", icon="pc") + self.device = Device.objects.create(name="Desktop") + self.game = Game.objects.create(name="Test Game", platform=self.platform) + + def assertNoEscapedTags(self, html): + for marker in _ESCAPED_TAG_MARKERS: + self.assertNotIn(marker, html, f"double-escaped markup ({marker!r})") + + def _assert_shell(self, html, list_url, save_url): + """Markers every filter bar must keep through the refactor.""" + self.assertIn('id="filter-bar-form"', html) + self.assertIn('id="filter-json-input"', html) + self.assertIn('name="filter"', html) + self.assertIn(list_url, html) # preset list URL wired in + self.assertIn(save_url, html) # preset save URL wired in + self.assertNoEscapedTags(html) + + def test_game_filter_bar(self): + html = str( + FilterBar( + filter_json="", + preset_list_url="/presets/games/list", + preset_save_url="/presets/games/save", + ) + ) + self._assert_shell(html, "/presets/games/list", "/presets/games/save") + + def test_session_filter_bar(self): + html = str( + SessionFilterBar( + filter_json="", + preset_list_url="/presets/sessions/list", + preset_save_url="/presets/sessions/save", + ) + ) + self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save") + + def test_purchase_filter_bar(self): + html = str( + PurchaseFilterBar( + filter_json="", + preset_list_url="/presets/purchases/list", + preset_save_url="/presets/purchases/save", + ) + ) + self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save") + + def test_game_filter_bar_roundtrips_selected_status(self): + """A status in filter_json renders as a selected tag in the widget.""" + filter_json = json.dumps({"status": {"value": ["f"], "modifier": ""}}) + html = str( + FilterBar( + filter_json=filter_json, preset_list_url="/l", preset_save_url="/s" + ) + ) + self.assertIn("sf-tag", html) + self.assertIn('data-value="f"', html) # selected status reflected in widget + self.assertIn("Finished", html) # ...with its label + self.assertNoEscapedTags(html) + # The hidden #filter-json-input must be escaped exactly once, so the DOM + # value is valid JSON the apply/preset JS can re-parse. Regression guard + # for the double-escape bug the dedup fixed. + self.assertIn(""status"", html) + self.assertNotIn("&quot;", html) + + +class SelectableFilterTest(TestCase): + """The shared widget the deduped FilterBar will be built on.""" + + OPTIONS = [("f", "Finished"), ("a", "Abandoned"), ("u", "Unplayed")] + + def test_plain_widget_has_no_tags(self): + html = str(SelectableFilter("status", self.OPTIONS)) + self.assertNotIn("sf-tag", html) + + def test_include_and_exclude_tags(self): + html = str( + SelectableFilter("status", self.OPTIONS, selected=["f"], excluded=["a"]) + ) + self.assertIn('data-type="include"', html) + self.assertIn('data-type="exclude"', html) + self.assertIn("Finished", html) + self.assertIn("Abandoned", html) diff --git a/tests/test_filters.py b/tests/test_filters.py index e79feae..9a12553 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -10,11 +10,10 @@ from common.criteria import ( ChoiceCriterion, IntCriterion, Modifier, - MultiCriterion, StringCriterion, ) -from common.components import FilterBar, SelectableFilter -from games.filters import GameFilter, parse_game_filter +from common.components import FilterBar +from games.filters import GameFilter class TestStringCriterion: @@ -30,7 +29,9 @@ class TestStringCriterion: 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) + assert c.to_q("year_released") == Q( + year_released__gte=2020, year_released__lte=2024 + ) class TestBoolCriterion: @@ -67,7 +68,9 @@ class TestChoiceCriterion: 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) + 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"]) @@ -105,6 +108,7 @@ class TestChoiceCriterionAgainstDB: 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): @@ -115,11 +119,15 @@ class TestChoiceCriterionAgainstDB: 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)) + + 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): @@ -138,7 +146,9 @@ class TestChoiceCriterionAgainstDB: def test_include_and_exclude(self): """Include Finished but exclude Abandoned.""" self._seed_games() - c = ChoiceCriterion(value=["f", "a"], excludes=["a"], modifier=Modifier.INCLUDES) + c = ChoiceCriterion( + value=["f", "a"], excludes=["a"], modifier=Modifier.INCLUDES + ) # Include f and a, but exclude a → only f assert self._statuses(c) == {"f"} @@ -198,7 +208,10 @@ class TestGameFilterFromJson: assert gf.platform.value == ["1", "3"] def test_round_trip(self): - data = {"status": {"value": ["f"], "modifier": "INCLUDES"}, "mastered": {"value": True, "modifier": "EQUALS"}} + 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) @@ -236,7 +249,9 @@ class TestFilterBarRendering: html = str( FilterBar( platform_options=[], - filter_json=json.dumps({"mastered": {"value": True, "modifier": "EQUALS"}}), + filter_json=json.dumps( + {"mastered": {"value": True, "modifier": "EQUALS"}} + ), ) ) assert 'checked="true"' in html @@ -245,7 +260,9 @@ class TestFilterBarRendering: html = str( FilterBar( platform_options=[], - filter_json=json.dumps({"status": {"value": ["f"], "modifier": "INCLUDES"}}), + filter_json=json.dumps( + {"status": {"value": ["f"], "modifier": "INCLUDES"}} + ), ) ) assert 'data-value="f"' in html diff --git a/tests/test_middleware_integration.py b/tests/test_middleware_integration.py index cf69427..dbba414 100644 --- a/tests/test_middleware_integration.py +++ b/tests/test_middleware_integration.py @@ -18,9 +18,7 @@ class MiddlewareIntegrationTest(TestCase): @staticmethod def _create_user(): - return User.objects.create_user( - username="testuser", password="testpass123" - ) + return User.objects.create_user(username="testuser", password="testpass123") def setUp(self): self.client = Client() @@ -97,10 +95,10 @@ class MiddlewareIntegrationTest(TestCase): self.assertEqual(data["show-toast"]["message"], "Purchase refunded") # Verify the row HTML contains the updated row id body = response.content.decode() - self.assertIn(f'purchase-row-{purchase.id}', body) + self.assertIn(f"purchase-row-{purchase.id}", body) # Verify OoO modal close element - self.assertIn('hx-swap-oob', body) - self.assertIn('refund-confirmation-modal', body) + self.assertIn("hx-swap-oob", body) + self.assertIn("refund-confirmation-modal", body) # Verify the purchase is actually refunded purchase.refresh_from_db() self.assertIsNotNone(purchase.date_refunded) diff --git a/games/tests.py b/tests/test_price_update.py similarity index 100% rename from games/tests.py rename to tests/test_price_update.py diff --git a/tests/test_session_formatting.py b/tests/test_session_formatting.py index bbaf454..803ace3 100644 --- a/tests/test_session_formatting.py +++ b/tests/test_session_formatting.py @@ -16,9 +16,7 @@ class FormatDurationTest(TestCase): def test_duration_format(self): g = Game(name="The Test Game") g.save() - p = Purchase( - date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO) - ) + p = Purchase(date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)) p.save() p.games.add(g) p.save() diff --git a/tests/test_stats.py b/tests/test_stats.py index befd427..72f882e 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -47,14 +47,20 @@ class ComputeStatsTest(TestCase): # Game A in 2023: 1h + 1.5h on the same day = 2.5h Session.objects.create( - game=self.game_a, timestamp_start=dt(2023, 6, 10, 10), timestamp_end=dt(2023, 6, 10, 11) + game=self.game_a, + timestamp_start=dt(2023, 6, 10, 10), + timestamp_end=dt(2023, 6, 10, 11), ) Session.objects.create( - game=self.game_a, timestamp_start=dt(2023, 6, 10, 14), timestamp_end=dt(2023, 6, 10, 15, 30) + game=self.game_a, + timestamp_start=dt(2023, 6, 10, 14), + timestamp_end=dt(2023, 6, 10, 15, 30), ) # Game B in 2023: 1h tracked + 2h manual (no end) = 3h total Session.objects.create( - game=self.game_b, timestamp_start=dt(2023, 7, 1, 20), timestamp_end=dt(2023, 7, 1, 21) + game=self.game_b, + timestamp_start=dt(2023, 7, 1, 20), + timestamp_end=dt(2023, 7, 1, 21), ) Session.objects.create( game=self.game_b, @@ -63,7 +69,9 @@ class ComputeStatsTest(TestCase): ) # Game A in 2022 (only counts toward all-time): 2h Session.objects.create( - game=self.game_a, timestamp_start=dt(2022, 5, 1, 10), timestamp_end=dt(2022, 5, 1, 12) + game=self.game_a, + timestamp_start=dt(2022, 5, 1, 10), + timestamp_end=dt(2022, 5, 1, 12), ) # ── shared metrics (characterization) ── diff --git a/tests/test_streak.py b/tests/test_streak.py index 8ee5b90..77b7efc 100644 --- a/tests/test_streak.py +++ b/tests/test_streak.py @@ -5,7 +5,6 @@ from common.time import daterange, streak_bruteforce class StreakTest(unittest.TestCase): - def test_daterange_exclusive(self): d = daterange(date(2024, 8, 1), date(2024, 8, 3)) self.assertEqual( @@ -24,13 +23,15 @@ class StreakTest(unittest.TestCase): self.assertEqual(streak_bruteforce([date(2024, 8, 1)])["days"], 1) def test_2day_streak(self): - self.assertEqual(streak_bruteforce([date(2024, 8, 1), date(2024, 8, 2)])["days"], 2) + self.assertEqual( + streak_bruteforce([date(2024, 8, 1), date(2024, 8, 2)])["days"], 2 + ) def test_31day_streak(self): self.assertEqual( - streak_bruteforce(daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True))[ - "days" - ], + streak_bruteforce( + daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True) + )["days"], 31, )