From f4161bf3f4e8bf8366c07af27d6244106fc5bffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 6 Jun 2026 12:19:15 +0200 Subject: [PATCH] Improve stats code smells --- common/components.py | 1491 ++++++++++++++++++++++++++++++------- games/views/general.py | 476 +----------- games/views/stats_data.py | 351 +++++++++ tests/test_stats.py | 112 +++ 4 files changed, 1686 insertions(+), 744 deletions(-) create mode 100644 games/views/stats_data.py create mode 100644 tests/test_stats.py diff --git a/common/components.py b/common/components.py index 26fb8a8..e892b06 100644 --- a/common/components.py +++ b/common/components.py @@ -2,12 +2,12 @@ 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.db import models from django.utils.safestring import SafeText, mark_safe from common.icons import get_icon @@ -952,7 +952,11 @@ def LinkedPurchase(purchase: Purchase) -> SafeText: {"".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" + 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( @@ -1190,12 +1194,15 @@ def FilterBar( 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")) + 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 @@ -1232,10 +1239,18 @@ def FilterBar( 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 + 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 "" + 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: @@ -1245,14 +1260,16 @@ def FilterBar( except Exception: year_agg = {} try: - pt_agg = Game.objects.aggregate( - pt_max=models.Max("playtime") - ) + 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 + 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" @@ -1262,16 +1279,32 @@ def FilterBar( 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"), - ]), + 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", + ), + ], + ), ], ) @@ -1284,20 +1317,20 @@ def FilterBar( children=[ mark_safe( f'' f'' @@ -1309,134 +1342,331 @@ def FilterBar( 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..."]), - ]), - ]), - ]), - ]), + 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..."], + ), + ], + ), + ], + ), + ], + ), + ], + ), ], ) @@ -1479,14 +1709,14 @@ def SelectableFilter( label = _find_label(options, val) selected_html += ( f'' - f'\u2713 {escape(label)}' + f'\u2713 {escape(label)}' f' ' ) for val in excluded: label = _find_label(options, val) selected_html += ( f'' - f'\u2717 {escape(label)}' + f'\u2717 {escape(label)}' f' ' ) @@ -1504,23 +1734,40 @@ def SelectableFilter( return Component( tag_name="div", attributes=[ - ("class", "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium"), + ( + "class", + "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium", + ), ("data-selectable-filter", field_name), *([("data-modifier", modifier)] if modifier else []), ], children=[ - Component(tag_name="div", attributes=[ - ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"), - ], children=[mark_safe(active_mod_html + selected_html)]), - Component(tag_name="input", attributes=[ - ("type", "text"), - ("class", "sf-search block w-full border-0 border-t border-default-medium " - "bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden"), - ("placeholder", "Search\u2026"), - ]), - Component(tag_name="div", attributes=[ - ("class", "sf-options max-h-40 overflow-y-auto p-1"), - ], children=[mark_safe(inactive_mod_html + options_html)]), + 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)], + ), ], ) @@ -1533,103 +1780,464 @@ def _find_label(options: list[tuple[str, str]], value: str) -> str: def SessionFilterBar(filter_json="", preset_list_url="", preset_save_url=""): - from games.models import Game, Device, Session - game_opts = [(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")] - dev_opts = [(str(k), v) for k, v in Device.objects.order_by("name").values_list("id", "name")] + 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 + 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] + 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}" + 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 + 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 + 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")]), - ]) + 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=[ + ("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..."])]), - ]), - ]), - ]), - ]) + 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")] + + 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 + 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] + 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") @@ -1639,52 +2247,387 @@ def PurchaseFilterBar(filter_json="", preset_list_url="", preset_save_url=""): 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 + 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 + 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")]), - ]) + 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=[ + ("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..."])]), - ]), - ]), - ]), - ]) + 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/games/views/general.py b/games/views/general.py index 7ec223c..1be95eb 100644 --- a/games/views/general.py +++ b/games/views/general.py @@ -3,19 +3,9 @@ from typing import Any, Callable from django.contrib.auth.decorators import login_required from django.db.models import ( - Avg, - Count, - ExpressionWrapper, F, - Max, - OuterRef, - Prefetch, - Q, - Subquery, Sum, - fields, ) -from django.db.models.functions import TruncDate, TruncMonth from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect @@ -23,10 +13,10 @@ from django.urls import reverse from django.utils.timezone import now as timezone_now from common.layout import render_page -from common.time import available_stats_year_range, dateformat, format_duration -from common.utils import safe_division +from common.time import format_duration from games.models import Game, Platform, Purchase, Session from games.views.stats_content import stats_content +from games.views.stats_data import compute_stats def model_counts(request: HttpRequest) -> dict[str, bool]: @@ -75,210 +65,9 @@ def use_custom_redirect( @login_required def stats_alltime(request: HttpRequest) -> HttpResponse: - year = "Alltime" - this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game")) - this_year_sessions_with_durations = this_year_sessions.annotate( - duration=ExpressionWrapper( - F("timestamp_end") - F("timestamp_start"), - output_field=fields.DurationField(), - ) - ) - longest_session = this_year_sessions_with_durations.order_by("-duration").first() - this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct() - this_year_games_with_session_counts = this_year_games.annotate( - session_count=Count("sessions"), - ) - game_highest_session_count = this_year_games_with_session_counts.order_by( - "-session_count" - ).first() - selected_currency = "CZK" - unique_days = ( - this_year_sessions.annotate(date=TruncDate("timestamp_start")) - .values("date") - .distinct() - .aggregate(dates=Count("date")) - ) - this_year_played_purchases = Purchase.objects.filter( - games__sessions__in=this_year_sessions - ).distinct() - - this_year_purchases = Purchase.objects.all() - this_year_purchases_with_currency = this_year_purchases.select_related("games") - this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None) - this_year_purchases_refunded = Purchase.objects.refunded() - - this_year_purchases_unfinished_dropped_nondropped = ( - this_year_purchases_without_refunded.filter( - ~Q(games__status=Game.Status.FINISHED) - & ~Q(games__playevents__ended__isnull=False) - ) - .filter(infinite=False) - .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) - ) # do not count battle passes etc. - - this_year_purchases_unfinished = ( - this_year_purchases_unfinished_dropped_nondropped.filter( - ~Q(games__status=Game.Status.RETIRED) - & ~Q(games__status=Game.Status.ABANDONED) - ) - ) - this_year_purchases_dropped = ( - this_year_purchases.filter( - ~Q(games__status=Game.Status.FINISHED) - & ~Q(games__playevents__ended__isnull=False) - ) - .filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False)) - .filter(infinite=False) - .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) - ) - - this_year_purchases_without_refunded_count = ( - this_year_purchases_without_refunded.count() - ) - this_year_purchases_unfinished_count = this_year_purchases_unfinished.count() - this_year_purchases_unfinished_percent = int( - safe_division( - this_year_purchases_unfinished_count, - this_year_purchases_without_refunded_count, - ) - * 100 - ) - - _finished_purchases_qs = Purchase.objects.finished() - _finished_with_date = _finished_purchases_qs.annotate( - date_finished=Subquery( - Purchase.objects.filter(pk=OuterRef("pk")) - .annotate(max_ended=Max("games__playevents__ended")) - .values("max_ended")[:1] - ) - ) - purchases_finished_this_year = _finished_with_date - purchases_finished_this_year_released_this_year = _finished_with_date.order_by( - "-date_finished" - ) - - this_year_spendings = this_year_purchases_without_refunded.aggregate( - total_spent=Sum(F("converted_price")) - ) - total_spent = this_year_spendings["total_spent"] or 0 - - games_with_playtime = ( - Game.objects.filter(sessions__in=this_year_sessions) - .distinct() - .annotate(total_playtime=Sum(F("sessions__duration_total"))) - .filter(total_playtime__gt=timedelta(0)) - ) - month_playtimes = ( - this_year_sessions.annotate(month=TruncMonth("timestamp_start")) - .values("month") - .annotate(playtime=Sum("duration_total")) - .order_by("month") - ) - for month in month_playtimes: - month["playtime"] = format_duration(month["playtime"], "%2.0H") - - highest_session_average_game = ( - Game.objects.filter(sessions__in=this_year_sessions) - .annotate(session_average=Avg("sessions__duration_calculated")) - .order_by("-session_average") - .first() - ) - top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] - - total_playtime_per_platform = ( - this_year_sessions.values("game__platform__name") - .annotate(playtime=Sum(F("duration_total"))) - .annotate(platform_name=F("game__platform__name")) - .values("platform_name", "playtime") - .order_by("-playtime") - ) - - backlog_decrease_count = purchases_finished_this_year.count() - - first_play_date = "N/A" - last_play_date = "N/A" - first_play_game = None - last_play_game = None - if this_year_sessions: - first_session = this_year_sessions.earliest() - first_play_game = first_session.game - first_play_date = first_session.timestamp_start.strftime(dateformat) - last_session = this_year_sessions.latest() - last_play_game = last_session.game - last_play_date = last_session.timestamp_start.strftime(dateformat) - - all_purchased_this_year_count = this_year_purchases_with_currency.count() - all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count() - - this_year_purchases_dropped_count = this_year_purchases_dropped.count() - this_year_purchases_dropped_percentage = int( - safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count) - * 100 - ) - context = { - "total_hours": format_duration( - this_year_sessions.total_duration_unformatted(), "%2.0H" - ), - "total_year_games": this_year_played_purchases.all().count(), - "top_10_games_by_playtime": top_10_games_by_playtime, - "year": year, - "total_playtime_per_platform": total_playtime_per_platform, - "total_spent": total_spent, - "total_spent_currency": selected_currency, - "spent_per_game": int( - safe_division(total_spent, this_year_purchases_without_refunded_count) - ), - "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), - "total_sessions": this_year_sessions.count(), - "unique_days": unique_days["dates"], - "unique_days_percent": int(unique_days["dates"] / 365 * 100), - "purchased_unfinished_count": this_year_purchases_unfinished_count, - "unfinished_purchases_percent": this_year_purchases_unfinished_percent, - "dropped_count": this_year_purchases_dropped_count, - "dropped_percentage": this_year_purchases_dropped_percentage, - "refunded_percent": int( - safe_division( - all_purchased_refunded_this_year_count, - all_purchased_this_year_count, - ) - * 100 - ), - "all_purchased_refunded_this_year": this_year_purchases_refunded, - "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, - "all_purchased_this_year_count": all_purchased_this_year_count, - "backlog_decrease_count": backlog_decrease_count, - "longest_session_time": ( - format_duration(longest_session.duration, "%2.0Hh %2.0mm") - if longest_session - else 0 - ), - "longest_session_game": (longest_session.game if longest_session else None), - "highest_session_count": ( - game_highest_session_count.session_count - if game_highest_session_count - else 0 - ), - "highest_session_count_game": ( - game_highest_session_count if game_highest_session_count else None - ), - "highest_session_average": ( - format_duration( - highest_session_average_game.session_average, "%2.0Hh %2.0mm" - ) - if highest_session_average_game - else 0 - ), - "highest_session_average_game": highest_session_average_game, - "first_play_game": first_play_game, - "first_play_date": first_play_date, - "last_play_game": last_play_game, - "last_play_date": last_play_date, - "title": f"{year} Stats", - "stats_dropdown_year_range": available_stats_year_range(), - } - request.session["return_path"] = request.path - return render_page(request, stats_content(context), title=context["title"]) + data = compute_stats(None) + return render_page(request, stats_content(data), title=data["title"]) @login_required @@ -290,262 +79,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: ) if year == 0: return HttpResponseRedirect(reverse("games:stats_alltime")) - this_year_sessions = Session.objects.filter( - timestamp_start__year=year - ).prefetch_related("game") - this_year_sessions_with_durations = this_year_sessions.annotate( - duration=ExpressionWrapper( - F("timestamp_end") - F("timestamp_start"), - output_field=fields.DurationField(), - ) - ) - longest_session = this_year_sessions_with_durations.order_by("-duration").first() - this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct() - this_year_games_with_session_counts = this_year_games.annotate( - session_count=Count( - "sessions", - filter=Q(sessions__timestamp_start__year=year), - ) - ) - game_highest_session_count = this_year_games_with_session_counts.order_by( - "-session_count" - ).first() - selected_currency = "CZK" - unique_days = ( - this_year_sessions.annotate(date=TruncDate("timestamp_start")) - .values("date") - .distinct() - .aggregate(dates=Count("date")) - ) - this_year_played_purchases = Purchase.objects.filter( - games__sessions__in=this_year_sessions - ).distinct() - - this_year_played_games = Game.objects.filter( - sessions__in=this_year_sessions - ).distinct() - - this_year_purchases = Purchase.objects.filter( - date_purchased__year=year - ).prefetch_related("games") - # purchased this year - # not refunded - this_year_purchases_without_refunded = Purchase.objects.filter( - date_refunded=None, date_purchased__year=year - ) - - # purchased this year - # not refunded - # not finished - # not infinite - # only Game and DLC - this_year_purchases_unfinished_dropped_nondropped = ( - this_year_purchases_without_refunded.filter( - ~Q(games__status=Game.Status.FINISHED) - & ~Q(games__playevents__ended__year=year) - ) - .filter(infinite=False) - .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) - ) - - # unfinished = not finished AND not dropped - this_year_purchases_unfinished = ( - this_year_purchases_unfinished_dropped_nondropped.filter( - ~Q(games__status=Game.Status.RETIRED) - & ~Q(games__status=Game.Status.ABANDONED) - ) - ) - # dropped = abandoned OR retired OR refunded (OR logic for transition) - this_year_purchases_dropped = ( - this_year_purchases.filter( - ~Q(games__status=Game.Status.FINISHED) - & ~Q(games__playevents__ended__year=year) - ) - .filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False)) - .filter(infinite=False) - .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) - ) - - this_year_purchases_without_refunded_count = ( - this_year_purchases_without_refunded.count() - ) - this_year_purchases_unfinished_count = this_year_purchases_unfinished.count() - this_year_purchases_unfinished_percent = int( - safe_division( - this_year_purchases_unfinished_count, - this_year_purchases_without_refunded_count, - ) - * 100 - ) - - purchases_finished_this_year = ( - Purchase.objects.finished() - .filter(games__playevents__ended__year=year) - .annotate( - game_name=F("games__name"), date_finished=F("games__playevents__ended") - ) - ) - purchases_finished_this_year_released_this_year = ( - purchases_finished_this_year.filter(games__year_released=year).order_by( - "games__playevents__ended" - ) - ) - purchased_this_year_finished_this_year = ( - this_year_purchases_without_refunded.filter( - games__playevents__ended__year=year - ).annotate( - game_name=F("games__name"), date_finished=F("games__playevents__ended") - ) - ).order_by("games__playevents__ended") - - this_year_spendings = this_year_purchases_without_refunded.aggregate( - total_spent=Sum(F("converted_price")) - ) - total_spent = this_year_spendings["total_spent"] or 0 - - games_with_playtime = ( - Game.objects.filter(sessions__timestamp_start__year=year) - .annotate( - total_playtime=Sum( - F("sessions__duration_calculated"), - ) - ) - .filter(total_playtime__gt=timedelta(0)) - ) - - month_playtimes = ( - this_year_sessions.annotate(month=TruncMonth("timestamp_start")) - .values("month") - .annotate(playtime=Sum("duration_total")) - .order_by("month") - ) - - highest_session_average_game = ( - Game.objects.filter(sessions__in=this_year_sessions) - .annotate(session_average=Avg("sessions__duration_calculated")) - .order_by("-session_average") - .first() - ) - top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime") - - total_playtime_per_platform = ( - this_year_sessions.values("game__platform__name") - .annotate(playtime=Sum(F("duration_total"))) - .annotate(platform_name=F("game__platform__name")) - .values("platform_name", "playtime") - .order_by("-playtime") - ) - - backlog_decrease_count = ( - Purchase.objects.filter(date_purchased__year__lt=year) - .filter(games__status=Game.Status.FINISHED) - .filter(games__playevents__ended__year=year) - .count() - ) - - first_play_date = "N/A" - last_play_date = "N/A" - first_play_game = None - last_play_game = None - if this_year_sessions: - first_session = this_year_sessions.earliest() - first_play_game = first_session.game - first_play_date = first_session.timestamp_start.strftime(dateformat) - last_session = this_year_sessions.latest() - last_play_game = last_session.game - last_play_date = last_session.timestamp_start.strftime(dateformat) - - all_purchased_this_year_count = this_year_purchases.count() - this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter( - date_purchased__year=year - ) - all_purchased_refunded_this_year_count = this_year_purchases_refunded.count() - - this_year_purchases_dropped_count = this_year_purchases_dropped.count() - this_year_purchases_dropped_percentage = int( - safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count) - * 100 - ) - context = { - "total_hours": format_duration( - this_year_sessions.total_duration_unformatted(), "%2.0H" - ), - "total_games": this_year_played_games.count(), - "total_year_games": this_year_played_purchases.filter( - games__year_released=year - ).count(), - "top_10_games_by_playtime": top_10_games_by_playtime, - "year": year, - "total_playtime_per_platform": total_playtime_per_platform, - "total_spent": total_spent, - "total_spent_currency": selected_currency, - "spent_per_game": int( - safe_division(total_spent, this_year_purchases_without_refunded_count) - ), - "all_finished_this_year": purchases_finished_this_year.prefetch_related( - "games" - ).order_by("games__playevents__ended"), - "all_finished_this_year_count": purchases_finished_this_year.count(), - "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related( - "games" - ).order_by("games__playevents__ended"), - "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), - "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related( - "games" - ).order_by("games__playevents__ended"), - "total_sessions": this_year_sessions.count(), - "unique_days": unique_days["dates"], - "unique_days_percent": int(unique_days["dates"] / 365 * 100), - "purchased_unfinished": this_year_purchases_unfinished, - "purchased_unfinished_count": this_year_purchases_unfinished_count, - "unfinished_purchases_percent": this_year_purchases_unfinished_percent, - "dropped_count": this_year_purchases_dropped_count, - "dropped_percentage": this_year_purchases_dropped_percentage, - "refunded_percent": int( - safe_division( - all_purchased_refunded_this_year_count, - all_purchased_this_year_count, - ) - * 100 - ), - "all_purchased_refunded_this_year": this_year_purchases_refunded, - "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, - "all_purchased_this_year": this_year_purchases.order_by("date_purchased"), - "all_purchased_this_year_count": all_purchased_this_year_count, - "backlog_decrease_count": backlog_decrease_count, - "longest_session_time": ( - format_duration(longest_session.duration, "%2.0Hh %2.0mm") - if longest_session - else 0 - ), - "longest_session_game": (longest_session.game if longest_session else None), - "highest_session_count": ( - game_highest_session_count.session_count - if game_highest_session_count - else 0 - ), - "highest_session_count_game": ( - game_highest_session_count if game_highest_session_count else None - ), - "highest_session_average": ( - format_duration( - highest_session_average_game.session_average, "%2.0Hh %2.0mm" - ) - if highest_session_average_game - else 0 - ), - "highest_session_average_game": highest_session_average_game, - "first_play_game": first_play_game, - "first_play_date": first_play_date, - "last_play_game": last_play_game, - "last_play_date": last_play_date, - "title": f"{year} Stats", - "month_playtimes": month_playtimes, - "stats_dropdown_year_range": available_stats_year_range(), - } - request.session["return_path"] = request.path - return render_page(request, stats_content(context), title=context["title"]) + data = compute_stats(year) + return render_page(request, stats_content(data), title=data["title"]) @login_required diff --git a/games/views/stats_data.py b/games/views/stats_data.py new file mode 100644 index 0000000..4a408f8 --- /dev/null +++ b/games/views/stats_data.py @@ -0,0 +1,351 @@ +"""Request-free stats computation: the data half of the stats page. + +`compute_stats(year)` returns a `StatsData` dict (the documented seam between +*computing* metrics and *rendering* them in `stats_content`). Today it computes +from the ORM; this is also the function a future materialization job would call, +and the shape it would populate from a pre-calculated table. + +`year=None` means all-time; otherwise the metrics are scoped to that calendar +year. The two scopes genuinely diverge (different aggregations, and all-time +hides the per-purchase list sections), so the differences are kept explicit. +""" + +from datetime import date, timedelta +from typing import Any, NotRequired, TypedDict + +from django.db.models import ( + Avg, + Count, + ExpressionWrapper, + F, + Max, + OuterRef, + Q, + Subquery, + Sum, + fields, +) +from django.db.models.functions import TruncDate, TruncMonth + +from common.time import available_stats_year_range, dateformat, format_duration +from common.utils import safe_division +from games.models import Game, Purchase, Session + + +class StatsData(TypedDict): + # --- always present (both scopes) --- + year: Any # int for a year, "Alltime" for all-time + title: str + total_hours: str + total_sessions: int + unique_days: int + unique_days_percent: int + total_year_games: int + this_year_finished_this_year_count: int + top_10_games_by_playtime: Any + total_playtime_per_platform: Any + total_spent: Any + total_spent_currency: str + spent_per_game: int + all_purchased_this_year_count: int + all_purchased_refunded_this_year: Any + all_purchased_refunded_this_year_count: int + refunded_percent: int + dropped_count: int + dropped_percentage: int + purchased_unfinished_count: int + unfinished_purchases_percent: int + backlog_decrease_count: int + longest_session_time: Any + longest_session_game: Any + highest_session_count: int + highest_session_count_game: Any + highest_session_average: Any + highest_session_average_game: Any + first_play_game: Any + first_play_date: str + last_play_game: Any + last_play_date: str + stats_dropdown_year_range: Any + # --- per-year only (omitted for all-time, which hides these sections) --- + total_games: NotRequired[int] + month_playtimes: NotRequired[Any] + all_finished_this_year: NotRequired[Any] + all_finished_this_year_count: NotRequired[int] + this_year_finished_this_year: NotRequired[Any] + purchased_this_year_finished_this_year: NotRequired[Any] + purchased_unfinished: NotRequired[Any] + all_purchased_this_year: NotRequired[Any] + + +def _days_played_percent(unique_days: int, first: date, last: date) -> int: + """Share of days played across the span actually played (all-time). + + Unlike the per-year metric (``unique_days / 365``), the all-time span is the + real number of days between the first and last session, so the result stays + meaningful (and ≤100%) across multiple years. + """ + span = (last - first).days + 1 + if span <= 0: + return 0 + return min(int(unique_days / span * 100), 100) + + +def compute_stats(year: int | None = None) -> StatsData: + is_alltime = year is None + currency = "CZK" + + # ── Scope ────────────────────────────────────────────────────────────── + if is_alltime: + sessions = Session.objects.all().prefetch_related("game") + purchases = Purchase.objects.all() + without_refunded = Purchase.objects.filter(date_refunded=None) + refunded = Purchase.objects.refunded() + ended_q = Q(games__playevents__ended__isnull=False) + session_count = Count("sessions") + else: + sessions = Session.objects.filter(timestamp_start__year=year).prefetch_related( + "game" + ) + purchases = Purchase.objects.filter(date_purchased__year=year) + without_refunded = Purchase.objects.filter( + date_refunded=None, date_purchased__year=year + ) + refunded = Purchase.objects.exclude(date_refunded=None).filter( + date_purchased__year=year + ) + ended_q = Q(games__playevents__ended__year=year) + session_count = Count( + "sessions", filter=Q(sessions__timestamp_start__year=year) + ) + + not_finished_q = ~Q(games__status=Game.Status.FINISHED) & ~ended_q + + # ── Session superlatives ───────────────────────────────────────────────── + longest_session = ( + sessions.annotate( + duration=ExpressionWrapper( + F("timestamp_end") - F("timestamp_start"), + output_field=fields.DurationField(), + ) + ) + .order_by("-duration") + .first() + ) + games_in_scope = Game.objects.filter(sessions__in=sessions).distinct() + highest_session_count_game = ( + games_in_scope.annotate(session_count=session_count) + .order_by("-session_count") + .first() + ) + highest_session_average_game = ( + Game.objects.filter(sessions__in=sessions) + .annotate(session_average=Avg("sessions__duration_calculated")) + .order_by("-session_average") + .first() + ) + + # ── Days played + play range ───────────────────────────────────────────── + unique_days = ( + sessions.annotate(date=TruncDate("timestamp_start")) + .values("date") + .distinct() + .aggregate(dates=Count("date"))["dates"] + ) + first_session = sessions.earliest() if sessions.exists() else None + last_session = sessions.latest() if sessions.exists() else None + first_play_game = first_session.game if first_session else None + last_play_game = last_session.game if last_session else None + first_play_date = ( + first_session.timestamp_start.strftime(dateformat) if first_session else "N/A" + ) + last_play_date = ( + last_session.timestamp_start.strftime(dateformat) if last_session else "N/A" + ) + if is_alltime: + unique_days_percent = ( + _days_played_percent( + unique_days, + first_session.timestamp_start.date(), + last_session.timestamp_start.date(), + ) + if first_session + else 0 + ) + else: + unique_days_percent = int(unique_days / 365 * 100) + + # ── Spending ───────────────────────────────────────────────────────────── + total_spent = without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0 + without_refunded_count = without_refunded.count() + + # ── Purchase breakdown ─────────────────────────────────────────────────── + only_games_and_dlc = Q(type=Purchase.GAME) | Q(type=Purchase.DLC) + unfinished = ( + 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)) + ) + dropped = ( + purchases.filter(not_finished_q) + .filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False)) + .filter(infinite=False) + .filter(only_games_and_dlc) + ) + unfinished_count = unfinished.count() + dropped_count = dropped.count() + all_purchased_count = purchases.count() + refunded_count = refunded.count() + + # ── Finished purchases (scope-divergent) ───────────────────────────────── + if is_alltime: + finished = Purchase.objects.finished().annotate( + date_finished=Subquery( + Purchase.objects.filter(pk=OuterRef("pk")) + .annotate(max_ended=Max("games__playevents__ended")) + .values("max_ended")[:1] + ) + ) + finished_released = finished.order_by("-date_finished") + backlog_decrease_count = finished.count() + else: + finished = ( + Purchase.objects.finished() + .filter(games__playevents__ended__year=year) + .annotate( + game_name=F("games__name"), date_finished=F("games__playevents__ended") + ) + ) + finished_released = finished.filter(games__year_released=year).order_by( + "games__playevents__ended" + ) + purchased_finished = ( + without_refunded.filter(games__playevents__ended__year=year) + .annotate( + game_name=F("games__name"), date_finished=F("games__playevents__ended") + ) + .order_by("games__playevents__ended") + ) + backlog_decrease_count = ( + Purchase.objects.filter(date_purchased__year__lt=year) + .filter(games__status=Game.Status.FINISHED) + .filter(games__playevents__ended__year=year) + .count() + ) + + # ── Games / platforms by playtime (unified on duration_total) ──────────── + if is_alltime: + games_with_playtime = ( + Game.objects.filter(sessions__in=sessions) + .distinct() + .annotate(total_playtime=Sum("sessions__duration_total")) + .filter(total_playtime__gt=timedelta(0)) + ) + top_games = games_with_playtime.order_by("-total_playtime")[:10] + else: + games_with_playtime = ( + Game.objects.filter(sessions__timestamp_start__year=year) + .annotate(total_playtime=Sum("sessions__duration_total")) + .filter(total_playtime__gt=timedelta(0)) + ) + top_games = games_with_playtime.order_by("-total_playtime") + + total_playtime_per_platform = ( + sessions.values("game__platform__name") + .annotate(playtime=Sum(F("duration_total"))) + .annotate(platform_name=F("game__platform__name")) + .values("platform_name", "playtime") + .order_by("-playtime") + ) + + played_purchases = Purchase.objects.filter(games__sessions__in=sessions).distinct() + total_year_games = ( + played_purchases.count() + if is_alltime + else played_purchases.filter(games__year_released=year).count() + ) + + year_label = "Alltime" if is_alltime else year + data: StatsData = { + "year": year_label, + "title": f"{year_label} Stats", + "total_hours": format_duration( + sessions.total_duration_unformatted(), "%2.0H" + ), + "total_sessions": sessions.count(), + "unique_days": unique_days, + "unique_days_percent": unique_days_percent, + "total_year_games": total_year_games, + "this_year_finished_this_year_count": finished_released.count(), + "top_10_games_by_playtime": top_games, + "total_playtime_per_platform": total_playtime_per_platform, + "total_spent": total_spent, + "total_spent_currency": currency, + "spent_per_game": int(safe_division(total_spent, without_refunded_count)), + "all_purchased_this_year_count": all_purchased_count, + "all_purchased_refunded_this_year": refunded, + "all_purchased_refunded_this_year_count": refunded_count, + "refunded_percent": int( + safe_division(refunded_count, all_purchased_count) * 100 + ), + "dropped_count": dropped_count, + "dropped_percentage": int( + safe_division(dropped_count, all_purchased_count) * 100 + ), + "purchased_unfinished_count": unfinished_count, + "unfinished_purchases_percent": int( + safe_division(unfinished_count, without_refunded_count) * 100 + ), + "backlog_decrease_count": backlog_decrease_count, + "longest_session_time": ( + format_duration(longest_session.duration, "%2.0Hh %2.0mm") + if longest_session + else 0 + ), + "longest_session_game": longest_session.game if longest_session else None, + "highest_session_count": ( + highest_session_count_game.session_count + if highest_session_count_game + else 0 + ), + "highest_session_count_game": highest_session_count_game, + "highest_session_average": ( + format_duration( + highest_session_average_game.session_average, "%2.0Hh %2.0mm" + ) + if highest_session_average_game + else 0 + ), + "highest_session_average_game": highest_session_average_game, + "first_play_game": first_play_game, + "first_play_date": first_play_date, + "last_play_game": last_play_game, + "last_play_date": last_play_date, + "stats_dropdown_year_range": available_stats_year_range(), + } + + if not is_alltime: + data["total_games"] = games_in_scope.count() + data["month_playtimes"] = ( + sessions.annotate(month=TruncMonth("timestamp_start")) + .values("month") + .annotate(playtime=Sum("duration_total")) + .order_by("month") + ) + data["all_finished_this_year"] = finished.prefetch_related("games").order_by( + "games__playevents__ended" + ) + data["all_finished_this_year_count"] = finished.count() + data["this_year_finished_this_year"] = finished_released.prefetch_related( + "games" + ).order_by("games__playevents__ended") + data["purchased_this_year_finished_this_year"] = ( + purchased_finished.prefetch_related("games").order_by( + "games__playevents__ended" + ) + ) + data["purchased_unfinished"] = unfinished + data["all_purchased_this_year"] = purchases.order_by("date_purchased") + + return data diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..befd427 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,112 @@ +"""Behaviour tests for the stats provider (compute_stats). + +Locks the metrics that must not change in the view-unification refactor, and +pins the two intentional fixes: all-time "days played %" is span-based, and +games-by-playtime uses duration_total (so manual sessions count). +""" + +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from django.conf import settings +from django.test import TestCase + +from games.models import Game, Platform, Session +from games.views.stats_data import _days_played_percent, compute_stats + +TZ = ZoneInfo(settings.TIME_ZONE) + + +class DaysPlayedPercentTest(TestCase): + """The span-based all-time percent must differ from the old /365.""" + + def test_span_based_differs_from_per_year(self): + first = datetime(2021, 1, 1).date() + last = datetime(2023, 12, 31).date() # ~1095-day span + # 100 unique days over a 3-year span = ~9%, not the old 100/365 = 27%. + self.assertEqual(_days_played_percent(100, first, last), 9) + + def test_capped_at_100_and_safe_on_empty_span(self): + d = datetime(2023, 1, 1).date() + self.assertEqual(_days_played_percent(5, d, d), 100) # 1-day span + self.assertEqual(_days_played_percent(0, d, d), 0) + + +class ComputeStatsTest(TestCase): + def setUp(self): + self.platform = Platform.objects.create(name="PC", icon="pc") + self.game_a = Game.objects.create( + name="Game A", platform=self.platform, year_released=2022 + ) + self.game_b = Game.objects.create( + name="Game B", platform=self.platform, year_released=2023 + ) + + def dt(y, mo, d, h, mi=0): + return datetime(y, mo, d, h, mi, tzinfo=TZ) + + # 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) + ) + Session.objects.create( + 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) + ) + Session.objects.create( + game=self.game_b, + timestamp_start=dt(2023, 7, 2, 12), + duration_manual=timedelta(hours=2), + ) + # 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) + ) + + # ── shared metrics (characterization) ── + + def test_session_and_day_counts(self): + year = compute_stats(2023) + alltime = compute_stats(None) + self.assertEqual(year["total_sessions"], 4) + self.assertEqual(alltime["total_sessions"], 5) + self.assertEqual(year["unique_days"], 3) # 06-10, 07-01, 07-02 + self.assertEqual(alltime["unique_days"], 4) # + 2022-05-01 + + def test_per_year_percent_is_over_365(self): + self.assertEqual(compute_stats(2023)["unique_days_percent"], int(3 / 365 * 100)) + + def test_alltime_percent_is_span_based_and_sane(self): + pct = compute_stats(None)["unique_days_percent"] + self.assertGreaterEqual(pct, 0) + self.assertLessEqual(pct, 100) + + # ── the duration_total fix ── + + def test_games_by_playtime_includes_manual_sessions(self): + """In 2023, Game B's manual 2h must count, putting it (3h) above A (2.5h).""" + top = list(compute_stats(2023)["top_10_games_by_playtime"]) + self.assertEqual(top[0].id, self.game_b.id) + self.assertEqual(top[0].total_playtime, timedelta(hours=3)) + + def test_alltime_playtime_sums_all_years(self): + """All-time Game A = 2.5h (2023) + 2h (2022) = 4.5h, ahead of B (3h).""" + top = list(compute_stats(None)["top_10_games_by_playtime"]) + self.assertEqual(top[0].id, self.game_a.id) + self.assertEqual(top[0].total_playtime, timedelta(hours=4, minutes=30)) + + # ── section visibility (scope difference preserved) ── + + def test_alltime_omits_per_year_list_sections(self): + alltime = compute_stats(None) + year = compute_stats(2023) + for key in ("month_playtimes", "all_purchased_this_year", "total_games"): + self.assertNotIn(key, alltime) + self.assertIn(key, year) + + def test_year_label(self): + self.assertEqual(compute_stats(None)["year"], "Alltime") + self.assertEqual(compute_stats(2023)["year"], 2023)