Compare commits

...

2 Commits

Author SHA1 Message Date
lukas ed8589a972 Fix more code smells
Django CI/CD / test (push) Successful in 39s
Django CI/CD / build-and-push (push) Successful in 1m19s
2026-06-06 13:14:55 +02:00
lukas f4161bf3f4 Improve stats code smells 2026-06-06 12:19:15 +02:00
44 changed files with 4049 additions and 1373 deletions
-48
View File
@@ -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")
File diff suppressed because it is too large Load Diff
+94
View File
@@ -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",
]
+74
View File
@@ -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 ``"<b>"``
and an unsafe ``"<b>"`` (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}</{tag_name}>"
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
+345
View File
@@ -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"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{game_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul>
"""
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"<template x-if=\"status == '{value}'\">"
f"{GameStatus(status=value, children=[label], display='flex')}"
f"</template>"
for value, label in game_statuses
)
list_items = "\n".join(
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': status === '{value}'}}\">"
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
f"</a></li>"
for value, label in game_statuses
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
status: '{game.status}',
status_display: '{game.get_status_display()}',
open: false,
saving: false,
setStatus(newStatus, newStatusDisplay) {{
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ status: newStatus }})
}})
.then(() => {{
document.body.dispatchEvent(new CustomEvent('status-changed'));
}})
.catch(() => {{
console.error('Failed to update status');
}})
.finally(() => this.saving = false);
}}
}}">
{_dropdown_button_html(options_html, list_items)}
</div>
""")
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'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
for d in session_devices
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
originalDeviceId: {device_id},
originalDeviceName: '{device_name}',
deviceId: {device_id},
deviceName: '{device_name}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {{
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ device_id: newDeviceId }})
}})
.then((res) => {{
document.body.dispatchEvent(new CustomEvent('device-changed'));
}})
.catch(() => {{
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
}})
.finally(() => this.saving = false);
}}
}}">
{
_dropdown_button_html(
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
)
}
</div>
""")
def _dropdown_button_html(button_content: str, list_items: str) -> str:
"""Shared dropdown button + list structure for Alpine.js selectors."""
return (
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
'<button type="button" @click="open = !open" '
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
"rounded-lg 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:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
'dark:focus:text-white align-middle hover:cursor-pointer">'
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
f"{list_items}"
"</ul>"
"</div>"
"</button>"
"</div>"
)
+782
View File
@@ -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: <div><label>…</label>{widget}</div>."""
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 <input type=range> 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'<input type="range" class="range-min absolute w-full pointer-events-none '
f"appearance-none bg-transparent h-2 "
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 "
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full "
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer "
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10" '
f'data-target="{min_id}" data-peer="{max_id}" '
f'min="{dmin}" max="{dmax}" value="{mv}" step="{step}">'
f'<input type="range" class="range-max absolute w-full pointer-events-none '
f"appearance-none bg-transparent h-2 "
f"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 "
f"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full "
f"[&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer "
f'[&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-20" '
f'data-target="{max_id}" data-peer="{min_id}" '
f'min="{dmin}" max="{dmax}" value="{xv}" step="{step}">'
),
],
)
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(
'<div class="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-neutral-secondary-medium border border-default-medium"></div><div class="range-track-fill absolute top-1/2 -translate-y-1/2 h-2 bg-brand rounded-full" style="left:0;width:100%"></div>'
+ f'<div class="range-handle-min absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{min_id}" style="left:0"></div><div class="range-handle-max absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand rounded-full border-2 border-white shadow cursor-pointer hover:scale-110 transition-transform" data-target="{max_id}" style="left:100%"></div>'
),
],
)
_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(
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'
),
"Filters",
],
)
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'<span class="sf-modifier-tag active" data-modifier="{mod_val}">'
f"{mod_label}</span> "
)
else:
inactive_mod_html += (
f'<div class="sf-option sf-modifier-option" data-modifier="{mod_val}" '
f'data-label="{mod_label}">'
f'<span class="sf-option-label">{mod_label}</span></div>'
)
selected_html = ""
for val in selected:
label = _find_label(options, val)
selected_html += (
f'<span class="sf-tag" data-value="{escape(val)}" data-type="include">'
f'<span class="sf-tag-text text-body">\u2713 {escape(label)}</span>'
f'<button type="button" class="sf-remove">\u00d7</button></span> '
)
for val in excluded:
label = _find_label(options, val)
selected_html += (
f'<span class="sf-tag sf-excluded" data-value="{escape(val)}" data-type="exclude">'
f'<span class="sf-tag-text text-body">\u2717 {escape(label)}</span>'
f'<button type="button" class="sf-remove">\u00d7</button></span> '
)
options_html = ""
for val, label in options:
options_html += (
f'<div class="sf-option" data-value="{escape(val)}" data-label="{escape(label)}">'
f'<span class="sf-option-label">{escape(label)}</span>'
f'<span class="sf-option-buttons">'
f'<button type="button" class="sf-btn-include" data-action="include" title="Include">+</button>'
f'<button type="button" class="sf-btn-exclude" data-action="exclude" title="Exclude">\u2212</button>'
f"</span></div>"
)
return Component(
tag_name="div",
attributes=[
(
"class",
"sf-container border border-default-medium rounded-base bg-neutral-secondary-medium",
),
("data-selectable-filter", field_name),
*([("data-modifier", modifier)] if modifier else []),
],
children=[
Component(
tag_name="div",
attributes=[
("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"),
],
children=[mark_safe(active_mod_html + selected_html)],
),
Component(
tag_name="input",
attributes=[
("type", "text"),
(
"class",
"sf-search block w-full border-0 border-t border-default-medium "
"bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden",
),
("placeholder", "Search\u2026"),
],
),
Component(
tag_name="div",
attributes=[
("class", "sf-options max-h-40 overflow-y-auto p-1 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)
+784
View File
@@ -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
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->"
),
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 <a> 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 <button> inside <a>)."""
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
a_attrs: list[HTMLAttribute] = [("href", href)]
if hx_get:
a_attrs.append(("hx-get", hx_get))
if hx_target:
a_attrs.append(("hx-target", hx_target))
a_attrs.append(
(
"class",
"[&:first-of-type_button]:rounded-s-lg "
"[&:last-of-type_button]:rounded-e-lg",
)
)
button = Component(
tag_name="button",
attributes=[
("type", "button"),
("title", title),
("class", color_classes + " hover:cursor-pointer"),
],
children=[slot],
)
return Component(tag_name="a", attributes=a_attrs, children=[button])
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
"""Generate a button group div.
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
Empty dicts (no slot) are silently skipped — matching the template behavior
for conditional buttons (e.g., end-session only when session is active).
"""
buttons = buttons or []
children: list[SafeText] = []
for btn in buttons:
if not btn or not btn.get("slot"):
continue
children.append(
_button_group_button(
href=btn.get("href", "#"),
slot=btn["slot"],
color=btn.get("color", "gray"),
title=btn.get("title", ""),
hx_get=btn.get("hx_get", ""),
hx_target=btn.get("hx_target", ""),
)
)
return Component(
tag_name="div",
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
children=children,
)
def Div(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def CsrfInput(request) -> SafeText:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
return mark_safe(
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
)
def ModuleScript(filename: str) -> SafeText:
"""A `<script type="module">` tag pointing at a static JS file."""
return mark_safe(
f'<script type="module" src="{static("js/" + filename)}"></script>'
)
def AddForm(
form,
*,
request,
fields: SafeText | str | None = None,
additional_row: SafeText | str = "",
submit_class: str = "mt-3",
) -> SafeText:
"""Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the
session form, which lays out its fields manually). `additional_row` holds
extra submit buttons rendered below the main Submit button. `submit_class`
is applied to the main Submit button (the session form passes "" to match
its original markup).
"""
field_markup = fields if fields is not None else mark_safe(form.as_div())
submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Component(
tag_name="form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
children=[
CsrfInput(request),
field_markup,
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
Div(
[("class", "submit-button-container")],
[additional_row] if additional_row else [],
),
],
)
return Div(
[("id", "add-form"), ("class", "max-width-container")],
[
Div(
[("id", "add-form"), ("class", "form-container max-w-xl mx-auto")],
[inner_form],
)
],
)
def SearchField(
search_string: str = "",
id: str = "search_string",
placeholder: str = "Search",
) -> SafeText:
"""Generate a search form with icon, input field, and submit button."""
return Component(
tag_name="form",
attributes=[("class", "max-w-md")],
children=[
Component(
tag_name="label",
attributes=[
("for", "search"),
("class", "block mb-2.5 text-sm font-medium text-heading sr-only"),
],
children=["Search"],
),
Component(
tag_name="div",
attributes=[("class", "relative")],
children=[
mark_safe(
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
'fill="none" viewBox="0 0 24 24">'
'<path stroke="currentColor" stroke-linecap="round" stroke-width="2" '
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
"</svg></div>"
),
Component(
tag_name="input",
attributes=[
("type", "search"),
("id", id),
("name", id),
("value", search_string),
(
"class",
"block w-full p-3 ps-9 bg-neutral-secondary-medium "
"border border-default-medium text-heading text-sm "
"rounded-base focus:ring-brand focus:border-brand "
"shadow-xs placeholder:text-body",
),
("placeholder", placeholder),
("required", ""),
],
),
Component(
tag_name="button",
attributes=[
("type", "submit"),
(
"class",
"absolute end-1.5 bottom-1.5 text-white bg-brand "
"hover:bg-brand-strong box-border border border-transparent "
"focus:ring-4 focus:ring-brand-medium shadow-xs font-medium "
"leading-5 rounded text-xs px-3 py-1.5 focus:outline-none "
"cursor-pointer",
),
],
children=["Search"],
),
],
),
],
)
def H1(
children: list[HTMLTag] | HTMLTag | None = None,
badge: str = "",
) -> SafeText:
"""Heading with optional badge count."""
children = children or []
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
badge_html = ""
if badge:
heading_class = "flex items-center " + heading_class
badge_html = Component(
tag_name="span",
attributes=[
(
"class",
"bg-blue-100 text-blue-800 text-2xl font-semibold me-2 "
"px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2",
),
],
children=[badge],
)
return Component(
tag_name="h1",
attributes=[("class", heading_class)],
children=(children if isinstance(children, list) else [children])
+ ([badge_html] if badge_html else []),
)
def Modal(
modal_id: str,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Modal overlay with container. Content (form, buttons) goes in children."""
children = children or []
outer = Component(
tag_name="div",
attributes=[
("id", modal_id),
(
"class",
"fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto "
"h-full w-full flex items-center justify-center",
),
],
children=[
Component(
tag_name="div",
attributes=[
(
"class",
"relative mx-auto p-5 border-accent border w-full max-w-md "
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
),
],
children=(children if isinstance(children, list) else [children]),
),
],
)
return mark_safe(str(outer))
def TableTd(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Styled table cell."""
children = children or []
return Component(
tag_name="td",
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
children=children if isinstance(children, list) else [children],
)
def TableRow(data: dict | list | None = None) -> SafeText:
"""Generate a <tr> from a row data dict or list.
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
- first cell is <th>, rest <td>.
List form: [...] — all cells are <td>.
"""
if data is None:
data = {}
if isinstance(data, dict):
row_id = data.get("row_id", "")
cells = data.get("cell_data", [])
else:
row_id = ""
cells = data
tr_class = (
"odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 "
"dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 "
"dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 "
"[&_a]:decoration-2 [&_td:last-child]:text-right"
)
tr_attrs: list[HTMLAttribute] = [("class", tr_class)]
if row_id:
tr_attrs.append(("id", row_id))
if isinstance(data, dict):
if data.get("hx_trigger"):
tr_attrs.append(("hx-trigger", data["hx_trigger"]))
if data.get("hx_get"):
tr_attrs.append(("hx-get", data["hx_get"]))
if data.get("hx_select"):
tr_attrs.append(("hx-select", data["hx_select"]))
if data.get("hx_swap"):
tr_attrs.append(("hx-swap", data["hx_swap"]))
cell_elements: list[SafeText] = []
for i, cell in enumerate(cells):
if i == 0:
cell_elements.append(
Component(
tag_name="th",
attributes=[
("scope", "row"),
(
"class",
"px-6 py-4 font-medium text-gray-900 "
"whitespace-nowrap dark:text-white",
),
],
children=[cell],
)
)
else:
cell_elements.append(TableTd(children=[cell]))
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
def Icon(
name: str,
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
return mark_safe(get_icon(name))
def TableHeader(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Table caption."""
children = children or []
return Component(
tag_name="caption",
attributes=[
(
"class",
"p-2 text-lg font-semibold rtl:text-left text-right "
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
),
],
children=children if isinstance(children, list) else [children],
)
def _page_url(request, page) -> str:
"""Current querystring with `page` replaced (mirrors {% param_replace %})."""
if request is None:
return f"?page={page}"
params = request.GET.copy()
params["page"] = page
return "?" + params.urlencode()
def _pagination_nav(page_obj, elided_page_range, request) -> str:
pages_html = ""
for page in elided_page_range:
if page != page_obj.number:
pages_html += (
f'<li><a href="{_page_url(request, page)}" '
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
"bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
f'dark:hover:text-white">{conditional_escape(page)}</a></li>'
)
else:
pages_html += (
'<li><a aria-current="page" '
'class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight '
"text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 "
f'dark:text-gray-200">{conditional_escape(page)}</a></li>'
)
if page_obj.has_previous():
prev_html = (
f'<a href="{_page_url(request, page_obj.previous_page_number())}" '
'class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 '
"bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
'dark:hover:text-white">Previous</a>'
)
else:
prev_html = (
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg "
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>'
)
if page_obj.has_next():
next_html = (
f'<a href="{_page_url(request, page_obj.next_page_number())}" '
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
"bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
'dark:hover:text-white">Next</a>'
)
else:
next_html = (
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg "
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>'
)
return (
'<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 '
'dark:bg-gray-900 sm:rounded-b-lg" aria-label="Table navigation">'
'<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 '
'md:mb-0 block w-full md:inline md:w-auto">'
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.start_index()}</span>—'
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.end_index()}</span> of '
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.paginator.count}</span></span>'
'<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8"><li>'
f"{prev_html}{pages_html}{next_html}"
"</li></ul></nav>"
)
def SimpleTable(
columns: list[str] | None = None,
rows: list | None = None,
header_action: SafeText | str | None = None,
page_obj=None,
elided_page_range=None,
request=None,
) -> SafeText:
"""Paginated table. Python equivalent of the old simple_table.html."""
columns = columns or []
rows = rows or []
header_html = ""
if header_action:
header_html = str(TableHeader(children=[header_action]))
columns_html = "".join(
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
for col in columns
)
rows_html = "".join(str(TableRow(data=row)) for row in rows)
pagination_html = ""
if page_obj and elided_page_range:
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
return mark_safe(
'<div class="shadow-md" hx-boost="false">'
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
f"{header_html}"
'<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 '
'dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">'
f"<tr>{columns_html}</tr></thead>"
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
f"{rows_html}</tbody></table></div>"
f"{pagination_html}</div>"
)
def paginated_table_content(
data: dict,
*,
page_obj=None,
elided_page_range=None,
request=None,
) -> SafeText:
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
`data` is the table dict with keys ``columns``, ``rows`` and
``header_action`` (the same shape every list view already builds).
"""
return Div(
[
(
"class",
"2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) "
"md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center",
)
],
[
SimpleTable(
columns=data["columns"],
rows=data["rows"],
header_action=data["header_action"],
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
],
)
+45 -19
View File
@@ -38,19 +38,27 @@ class Modifier(str, Enum):
@classmethod @classmethod
def for_strings(cls) -> list[Self]: def for_strings(cls) -> list[Self]:
return [ return [
cls.EQUALS, cls.NOT_EQUALS, cls.EQUALS,
cls.INCLUDES, cls.EXCLUDES, cls.NOT_EQUALS,
cls.MATCHES_REGEX, cls.NOT_MATCHES_REGEX, cls.INCLUDES,
cls.IS_NULL, cls.NOT_NULL, cls.EXCLUDES,
cls.MATCHES_REGEX,
cls.NOT_MATCHES_REGEX,
cls.IS_NULL,
cls.NOT_NULL,
] ]
@classmethod @classmethod
def for_numbers(cls) -> list[Self]: def for_numbers(cls) -> list[Self]:
return [ return [
cls.EQUALS, cls.NOT_EQUALS, cls.EQUALS,
cls.GREATER_THAN, cls.LESS_THAN, cls.NOT_EQUALS,
cls.BETWEEN, cls.NOT_BETWEEN, cls.GREATER_THAN,
cls.IS_NULL, cls.NOT_NULL, cls.LESS_THAN,
cls.BETWEEN,
cls.NOT_BETWEEN,
cls.IS_NULL,
cls.NOT_NULL,
] ]
@classmethod @classmethod
@@ -60,9 +68,11 @@ class Modifier(str, Enum):
@classmethod @classmethod
def for_multi(cls) -> list[Self]: def for_multi(cls) -> list[Self]:
return [ return [
cls.INCLUDES, cls.EXCLUDES, cls.INCLUDES,
cls.EXCLUDES,
cls.INCLUDES_ALL, cls.INCLUDES_ALL,
cls.IS_NULL, cls.NOT_NULL, cls.IS_NULL,
cls.NOT_NULL,
] ]
@@ -152,8 +162,12 @@ class IntCriterion(_Criterion):
if m == Modifier.BETWEEN: if m == Modifier.BETWEEN:
if self.value2 is None: if self.value2 is None:
raise ValueError("BETWEEN requires value2") raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2), return Q(
f"{field_name}__lte": max(self.value, self.value2)}) **{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN: if m == Modifier.NOT_BETWEEN:
if self.value2 is None: if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2") raise ValueError("NOT_BETWEEN requires value2")
@@ -185,8 +199,12 @@ class FloatCriterion(_Criterion):
if m == Modifier.BETWEEN: if m == Modifier.BETWEEN:
if self.value2 is None: if self.value2 is None:
raise ValueError("BETWEEN requires value2") raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2), return Q(
f"{field_name}__lte": max(self.value, self.value2)}) **{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN: if m == Modifier.NOT_BETWEEN:
if self.value2 is None: if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2") raise ValueError("NOT_BETWEEN requires value2")
@@ -218,12 +236,15 @@ class DateCriterion(_Criterion):
if m == Modifier.BETWEEN: if m == Modifier.BETWEEN:
if self.value2 is None: if self.value2 is None:
raise ValueError("BETWEEN requires value2") raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": self.value, return Q(
f"{field_name}__lte": self.value2}) **{f"{field_name}__gte": self.value, f"{field_name}__lte": self.value2}
)
if m == Modifier.NOT_BETWEEN: if m == Modifier.NOT_BETWEEN:
if self.value2 is None: if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2") raise ValueError("NOT_BETWEEN requires value2")
return Q(**{f"{field_name}__lt": self.value}) | Q(**{f"{field_name}__gt": self.value2}) return Q(**{f"{field_name}__lt": self.value}) | Q(
**{f"{field_name}__gt": self.value2}
)
if m == Modifier.IS_NULL: if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True}) return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL: if m == Modifier.NOT_NULL:
@@ -248,6 +269,7 @@ class BoolCriterion(_Criterion):
@dataclass @dataclass
class MultiCriterion(_Criterion): class MultiCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list.""" """Filter on a many-to-many or ForeignKey relationship by ID list."""
value: list[int] = field(default_factory=list) value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list) excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES modifier: Modifier = Modifier.INCLUDES
@@ -407,9 +429,13 @@ class OperatorFilter:
f_type = f_type.split("|")[0].strip() f_type = f_type.split("|")[0].strip()
if isinstance(f_type, str) and f_type in criterion_types: if isinstance(f_type, str) and f_type in criterion_types:
criterion_cls = criterion_types[f_type] criterion_cls = criterion_types[f_type]
kwargs[f.name] = criterion_cls.from_json(raw) if isinstance(raw, dict) else None kwargs[f.name] = (
criterion_cls.from_json(raw) if isinstance(raw, dict) else None
)
elif isinstance(f_type, type) and issubclass(f_type, _Criterion): elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
kwargs[f.name] = f_type.from_json(raw) if isinstance(raw, dict) else None kwargs[f.name] = (
f_type.from_json(raw) if isinstance(raw, dict) else None
)
return cls(**kwargs) return cls(**kwargs)
def to_json(self) -> dict[str, Any]: def to_json(self) -> dict[str, Any]:
+1 -3
View File
@@ -1,9 +1,7 @@
import functools import functools
from pathlib import Path from pathlib import Path
_ICON_DIR = ( _ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
)
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
+15 -15
View File
@@ -187,7 +187,7 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
logo = static("icons/schedule.png") logo = static("icons/schedule.png")
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default"> return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4"> <div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{reverse('games:index')}" <a href="{reverse("games:index")}"
class="flex items-center space-x-3 rtl:space-x-reverse"> class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" /> <img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span> <span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
@@ -229,11 +229,11 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
</button> </button>
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600"> <div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton"> <ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse('games:add_device')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li> <li><a href="{reverse("games:add_device")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
<li><a href="{reverse('games:add_game')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li> <li><a href="{reverse("games:add_game")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
<li><a href="{reverse('games:add_platform')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li> <li><a href="{reverse("games:add_platform")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
<li><a href="{reverse('games:add_purchase')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li> <li><a href="{reverse("games:add_purchase")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
<li><a href="{reverse('games:add_session')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li> <li><a href="{reverse("games:add_session")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
</ul> </ul>
</div> </div>
</li> </li>
@@ -247,20 +247,20 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
</button> </button>
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600"> <div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton"> <ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse('games:list_devices')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li> <li><a href="{reverse("games:list_devices")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
<li><a href="{reverse('games:list_games')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li> <li><a href="{reverse("games:list_games")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
<li><a href="{reverse('games:list_platforms')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li> <li><a href="{reverse("games:list_platforms")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
<li><a href="{reverse('games:list_playevents')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li> <li><a href="{reverse("games:list_playevents")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
<li><a href="{reverse('games:list_purchases')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li> <li><a href="{reverse("games:list_purchases")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
<li><a href="{reverse('games:list_sessions')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li> <li><a href="{reverse("games:list_sessions")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
</ul> </ul>
</div> </div>
</li> </li>
<li> <li>
<a href="{reverse('games:stats_by_year', args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a> <a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li> </li>
<li> <li>
<a href="{reverse('logout')}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a> <a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -327,7 +327,7 @@ def Page(
" </div>\n" " </div>\n"
f" {scripts}\n" f" {scripts}\n"
f" {_main_script(mastered)}\n" f" {_main_script(mastered)}\n"
' <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n' " <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n' ' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
f" {_TOAST_CONTAINER}\n" f" {_TOAST_CONTAINER}\n"
f' <script src="{static("js/toast.js")}"></script>\n' f' <script src="{static("js/toast.js")}"></script>\n'
+3 -2
View File
@@ -104,7 +104,9 @@ class SessionDeviceUpdate(Schema):
@session_router.patch("/{session_id}/device", response={204: None}) @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 = get_object_or_404(Session, id=session_id)
session.device_id = payload.device_id session.device_id = payload.device_id
session.save() session.save()
@@ -113,4 +115,3 @@ def partial_update_session_device(request, session_id: int, payload: SessionDevi
api.add_router("/session", session_router) api.add_router("/session", session_router)
+55 -38
View File
@@ -13,6 +13,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from django.db.models import Q
from common.criteria import ( from common.criteria import (
BoolCriterion, BoolCriterion,
ChoiceCriterion, ChoiceCriterion,
@@ -32,11 +34,11 @@ from common.criteria import (
class FindFilter: class FindFilter:
"""Sorting and pagination, separate from filtering criteria (Stash-style).""" """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 page: int = 1
per_page: int = 25 per_page: int = 25
sort: str | None = None # e.g. "-created_at" sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc direction: str = "desc" # asc / desc
# ── GameFilter ───────────────────────────────────────────────────────────── # ── GameFilter ─────────────────────────────────────────────────────────────
@@ -55,19 +57,17 @@ class GameFilter(OperatorFilter):
year_released: IntCriterion | None = None year_released: IntCriterion | None = None
original_year_released: IntCriterion | None = None original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget platform: ChoiceCriterion | None = None # selectable filter widget
status: ChoiceCriterion | None = None # selectable filter widget status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None mastered: BoolCriterion | None = None
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q() playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string updated_at: StringCriterion | None = None # date string
# Free-text search (combines name + sort_name + platform name) # Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None search: StringCriterion | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported] def to_q(self) -> Q:
from django.db.models import Q
q = Q() q = Q()
# ── individual criteria ── # ── individual criteria ──
@@ -118,7 +118,7 @@ class GameFilter(OperatorFilter):
return q return q
@staticmethod @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. """Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert Django stores DurationField as microseconds in SQLite, so we convert
@@ -127,16 +127,25 @@ class GameFilter(OperatorFilter):
from datetime import timedelta from datetime import timedelta
from common.criteria import Modifier from common.criteria import Modifier
from django.db.models import Q
m = c.modifier m = c.modifier
field = "playtime" field = "playtime"
td_val = timedelta(minutes=c.value) td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS: 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: 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: if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val}) return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN: if m == Modifier.LESS_THAN:
@@ -167,15 +176,15 @@ class SessionFilter(OperatorFilter):
OR: SessionFilter | None = None OR: SessionFilter | None = None
NOT: SessionFilter | None = None NOT: SessionFilter | None = None
game: MultiCriterion | None = None # filters on game_id game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None emulated: BoolCriterion | None = None
note: StringCriterion | None = None note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0 is_manual: BoolCriterion | None = None # duration_manual > 0
created_at: StringCriterion | None = None created_at: StringCriterion | None = None
# Free-text search # Free-text search
@@ -184,11 +193,9 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria # Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None 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 datetime import timedelta
from django.db.models import Q
q = Q() q = Q()
if self.game is not None: if self.game is not None:
@@ -205,9 +212,19 @@ class SessionFilter(OperatorFilter):
field = "duration_total" field = "duration_total"
m = c.modifier m = c.modifier
if m == Modifier.EQUALS: 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: 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: elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val}) q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN: elif m == Modifier.LESS_THAN:
@@ -256,6 +273,7 @@ class SessionFilter(OperatorFilter):
# Cross-entity filter: sessions for games matching GameFilter # Cross-entity filter: sessions for games matching GameFilter
if self.game_filter is not None: if self.game_filter is not None:
from games.models import Game from games.models import Game
game_q = self.game_filter.to_q() game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True) matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids) q &= Q(game_id__in=matching_ids)
@@ -285,17 +303,17 @@ class PurchaseFilter(OperatorFilter):
NOT: PurchaseFilter | None = None NOT: PurchaseFilter | None = None
name: StringCriterion | None = None name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs) games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None converted_price: FloatCriterion | None = None
price_currency: StringCriterion | None = None price_currency: StringCriterion | None = None
num_purchases: IntCriterion | None = None num_purchases: IntCriterion | None = None
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
created_at: StringCriterion | None = None created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None updated_at: StringCriterion | None = None
@@ -305,9 +323,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity: purchases for games matching these criteria # Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported] def to_q(self) -> Q:
from django.db.models import Q
q = Q() q = Q()
if self.name is not None: if self.name is not None:
@@ -353,6 +369,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity filter # Cross-entity filter
if self.game_filter is not None: if self.game_filter is not None:
from games.models import Game from games.models import Game
game_q = self.game_filter.to_q() game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True) matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids) q &= Q(games__id__in=matching_ids)
+3 -1
View File
@@ -43,7 +43,9 @@ class SessionForm(forms.ModelForm):
), ),
label="Manual duration", label="Manual duration",
) )
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False) device = forms.ModelChoiceField(
queryset=Device.objects.order_by("name"), required=False
)
mark_as_played = forms.BooleanField( mark_as_played = forms.BooleanField(
required=False, required=False,
+4 -2
View File
@@ -34,9 +34,11 @@ class HTMXMessagesMiddleware:
if "HX-Redirect" in response: if "HX-Redirect" in response:
return 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) 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) backend._set_level(min_level)
messages = list(backend) messages = list(backend)
if not messages: if not messages:
+227 -61
View File
@@ -6,99 +6,265 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Device', name="Device",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255)), "id",
('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)), models.BigAutoField(
('created_at', models.DateTimeField(auto_now_add=True)), 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( migrations.CreateModel(
name='Platform', name="Platform",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255)), "id",
('group', models.CharField(blank=True, default=None, max_length=255, null=True)), models.BigAutoField(
('icon', models.SlugField(blank=True)), auto_created=True,
('created_at', models.DateTimeField(auto_now_add=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( migrations.CreateModel(
name='ExchangeRate', name="ExchangeRate",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('currency_from', models.CharField(max_length=255)), "id",
('currency_to', models.CharField(max_length=255)), models.BigAutoField(
('year', models.PositiveIntegerField()), auto_created=True,
('rate', models.FloatField()), 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={ options={
'unique_together': {('currency_from', 'currency_to', 'year')}, "unique_together": {("currency_from", "currency_to", "year")},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Game', name="Game",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255)), "id",
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)), models.BigAutoField(
('year_released', models.IntegerField(blank=True, default=None, null=True)), auto_created=True,
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)), primary_key=True,
('created_at', models.DateTimeField(auto_now_add=True)), serialize=False,
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')), 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={ options={
'unique_together': {('name', 'platform', 'year_released')}, "unique_together": {("name", "platform", "year_released")},
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Purchase', name="Purchase",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('date_purchased', models.DateField()), "id",
('date_refunded', models.DateField(blank=True, null=True)), models.BigAutoField(
('date_finished', models.DateField(blank=True, null=True)), auto_created=True,
('date_dropped', models.DateField(blank=True, null=True)), primary_key=True,
('infinite', models.BooleanField(default=False)), serialize=False,
('price', models.FloatField(default=0)), verbose_name="ID",
('price_currency', models.CharField(default='USD', max_length=3)), ),
('converted_price', models.FloatField(null=True)), ),
('converted_currency', models.CharField(max_length=3, null=True)), ("date_purchased", models.DateField()),
('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)), ("date_refunded", models.DateField(blank=True, null=True)),
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)), ("date_finished", models.DateField(blank=True, null=True)),
('name', models.CharField(blank=True, default='', max_length=255, null=True)), ("date_dropped", models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)), ("infinite", models.BooleanField(default=False)),
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')), ("price", models.FloatField(default=0)),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')), ("price_currency", models.CharField(default="USD", max_length=3)),
('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')), ("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( migrations.CreateModel(
name='Session', name="Session",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('timestamp_start', models.DateTimeField()), "id",
('timestamp_end', models.DateTimeField(blank=True, null=True)), models.BigAutoField(
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)), auto_created=True,
('duration_calculated', models.DurationField(blank=True, null=True)), primary_key=True,
('note', models.TextField(blank=True, null=True)), serialize=False,
('emulated', models.BooleanField(default=False)), verbose_name="ID",
('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')), ("timestamp_start", models.DateTimeField()),
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')), ("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={ options={
'get_latest_by': 'timestamp_start', "get_latest_by": "timestamp_start",
}, },
), ),
] ]
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0001_initial'), ("games", "0001_initial"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='purchase', model_name="purchase",
name='price_per_game', name="price_per_game",
field=models.FloatField(null=True), field=models.FloatField(null=True),
), ),
] ]
+3 -4
View File
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0002_purchase_price_per_game'), ("games", "0002_purchase_price_per_game"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='purchase', model_name="purchase",
name='updated_at', name="updated_at",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
] ]
@@ -5,55 +5,66 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0005_game_mastered_game_status'), ("games", "0005_game_mastered_game_status"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='game', model_name="game",
name='sort_name', name="sort_name",
field=models.CharField(blank=True, default='', max_length=255), field=models.CharField(blank=True, default="", max_length=255),
), ),
migrations.AlterField( migrations.AlterField(
model_name='game', model_name="game",
name='wikidata', name="wikidata",
field=models.CharField(blank=True, default='', max_length=50), field=models.CharField(blank=True, default="", max_length=50),
), ),
migrations.AlterField( migrations.AlterField(
model_name='platform', model_name="platform",
name='group', name="group",
field=models.CharField(blank=True, default='', max_length=255), field=models.CharField(blank=True, default="", max_length=255),
), ),
migrations.AlterField( migrations.AlterField(
model_name='purchase', model_name="purchase",
name='converted_currency', name="converted_currency",
field=models.CharField(blank=True, default='', max_length=3), field=models.CharField(blank=True, default="", max_length=3),
), ),
migrations.AlterField( migrations.AlterField(
model_name='purchase', model_name="purchase",
name='games', name="games",
field=models.ManyToManyField(related_name='purchases', to='games.game'), field=models.ManyToManyField(related_name="purchases", to="games.game"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='purchase', model_name="purchase",
name='name', name="name",
field=models.CharField(blank=True, default='', max_length=255), field=models.CharField(blank=True, default="", max_length=255),
), ),
migrations.AlterField( migrations.AlterField(
model_name='purchase', model_name="purchase",
name='related_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'), field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='session', model_name="session",
name='game', name="game",
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'), field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="sessions",
to="games.game",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='session', model_name="session",
name='note', name="note",
field=models.TextField(blank=True, default=''), field=models.TextField(blank=True, default=""),
), ),
] ]
+3 -4
View File
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'), ("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='game', model_name="game",
name='updated_at', name="updated_at",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
] ]
@@ -4,18 +4,17 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'), ("games", "0008_game_original_year_released_gamestatuschange_and_more"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='purchase', model_name="purchase",
name='date_dropped', name="date_dropped",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='purchase', model_name="purchase",
name='date_finished', name="date_finished",
), ),
] ]
@@ -4,14 +4,13 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'), ("games", "0009_remove_purchase_date_dropped_and_more"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='purchase', model_name="purchase",
name='price_per_game', name="price_per_game",
), ),
] ]
@@ -6,15 +6,24 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0010_remove_purchase_price_per_game'), ("games", "0010_remove_purchase_price_per_game"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='purchase', model_name="purchase",
name='price_per_game', 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()), 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(),
),
), ),
] ]
@@ -5,15 +5,20 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0013_game_playtime'), ("games", "0013_game_playtime"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='session', model_name="session",
name='duration_total', 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()), field=models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.CombinedExpression(
models.F("duration_calculated"), "+", models.F("duration_manual")
),
output_field=models.DurationField(),
),
), ),
] ]
@@ -5,35 +5,39 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0014_session_duration_total'), ("games", "0014_session_duration_total"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='purchase', model_name="purchase",
name='date_purchased', name="date_purchased",
field=models.DateField(verbose_name='Purchased'), field=models.DateField(verbose_name="Purchased"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='purchase', model_name="purchase",
name='date_refunded', name="date_refunded",
field=models.DateField(blank=True, null=True, verbose_name='Refunded'), field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='session', model_name="session",
name='duration_manual', name="duration_manual",
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'), field=models.DurationField(
blank=True,
default=datetime.timedelta(0),
null=True,
verbose_name="Manual duration",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='session', model_name="session",
name='timestamp_end', name="timestamp_end",
field=models.DateTimeField(blank=True, null=True, verbose_name='End'), field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='session', model_name="session",
name='timestamp_start', name="timestamp_start",
field=models.DateTimeField(verbose_name='Start'), field=models.DateTimeField(verbose_name="Start"),
), ),
] ]
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0015_alter_purchase_date_purchased_and_more'), ("games", "0015_alter_purchase_date_purchased_and_more"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='purchase', model_name="purchase",
name='needs_price_update', name="needs_price_update",
field=models.BooleanField(db_index=True, default=True), field=models.BooleanField(db_index=True, default=True),
), ),
migrations.RunSQL( migrations.RunSQL(
+31 -12
View File
@@ -4,26 +4,45 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('games', '0016_add_needs_price_update'), ("games", "0016_add_needs_price_update"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='FilterPreset', name="FilterPreset",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255)), "id",
('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)), models.BigAutoField(
('find_filter', models.JSONField(blank=True, default=dict)), auto_created=True,
('object_filter', models.JSONField(blank=True, default=dict)), primary_key=True,
('ui_options', models.JSONField(blank=True, default=dict)), serialize=False,
('created_at', models.DateTimeField(auto_now_add=True)), verbose_name="ID",
('updated_at', models.DateTimeField(auto_now=True)), ),
),
("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={ options={
'ordering': ['name'], "ordering": ["name"],
}, },
), ),
] ]
+4 -2
View File
@@ -66,8 +66,10 @@ class Game(models.Model):
return self.name return self.name
def finished(self): def finished(self):
return (self.status == self.Status.FINISHED or return (
self.playevents.filter(ended__isnull=False).exists()) self.status == self.Status.FINISHED
or self.playevents.filter(ended__isnull=False).exists()
)
def abandoned(self): def abandoned(self):
return self.status == self.Status.ABANDONED return self.status == self.Status.ABANDONED
+3 -1
View File
@@ -60,7 +60,9 @@ def _save_converted_price(purchase, converted_price, needs_update):
purchase.converted_currency = currency_to purchase.converted_currency = currency_to
if needs_update: if needs_update:
purchase.needs_price_update = False 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(): def convert_prices():
+1 -1
View File
@@ -9,5 +9,5 @@ register = template.Library()
def randomid(seed: str = "") -> str: def randomid(seed: str = "") -> str:
content_hash = hashlib.sha1(seed.encode()).hexdigest() content_hash = hashlib.sha1(seed.encode()).hexdigest()
if seed: if seed:
return content_hash[:max(0, 10 - len(seed))] + seed return content_hash[: max(0, 10 - len(seed))] + seed
return content_hash[:10] return content_hash[:10]
+5 -1
View File
@@ -23,7 +23,11 @@ urlpatterns = [
path("game/add", game.add_game, name="add_game"), path("game/add", game.add_game, name="add_game"),
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"), path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
path("game/<int:game_id>/view", game.view_game, name="view_game"), path("game/<int:game_id>/view", game.view_game, name="view_game"),
path("game/<int:game_id>/delete/confirm", game.delete_game_confirmation, name="delete_game_confirmation"), path(
"game/<int:game_id>/delete/confirm",
game.delete_game_confirmation,
name="delete_game_confirmation",
),
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"), path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
path("game/list", game.list_games, name="list_games"), path("game/list", game.list_games, name="list_games"),
path("platform/add", platform.add_platform, name="add_platform"), path("platform/add", platform.add_platform, name="add_platform"),
+5 -12
View File
@@ -5,10 +5,10 @@ from urllib.parse import quote
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import mark_safe
from games.models import FilterPreset from games.models import FilterPreset
@@ -21,9 +21,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
items: list[str] = [] items: list[str] = []
for preset in presets: for preset in presets:
filter_json = ( filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
json.dumps(preset.object_filter) if preset.object_filter else ""
)
list_url = reverse(f"games:list_{mode}") list_url = reverse(f"games:list_{mode}")
delete_url = reverse("games:delete_preset", args=[preset.id]) delete_url = reverse("games:delete_preset", args=[preset.id])
@@ -40,14 +38,9 @@ def list_presets(request: HttpRequest) -> HttpResponse:
) )
if not items: if not items:
items = [ items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
'<li class="px-4 py-2 text-sm text-body italic">'
"No saved presets</li>"
]
return HttpResponse( return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>'))
mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')
)
@login_required @login_required
+177 -188
View File
@@ -148,7 +148,9 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
request, request,
content, content,
title="Manage games", 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 _game_overview_metrics(game: Game) -> dict[str, Any]:
def view_game(request: HttpRequest, game_id: int) -> HttpResponse: """Request-free header metrics: total session count, play range, and the
game = Game.objects.get(id=game_id) per-session average (excluding manually-logged sessions)."""
purchases = game.purchases.order_by("date_purchased")
sessions = game.sessions sessions = game.sessions
session_count = sessions.count() session_count = sessions.count()
session_count_without_manual = game.sessions.without_manual().count() session_count_without_manual = sessions.without_manual().count()
if sessions.exists(): if sessions.exists():
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y") start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
latest_session = sessions.latest() end = local_strftime(sessions.latest().timestamp_start, "%b %Y")
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y") playrange = start if start == end else f"{start}{end}"
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
else: else:
playrange = "N/A" playrange = "N/A"
latest_session = None
total_hours_without_manual = float( total_hours_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H") 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( session_average_without_manual = round(
safe_division(total_hours_without_manual, int(session_count_without_manual)), safe_division(total_hours_without_manual, int(session_count_without_manual)), 1
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" grey_value_class = "text-black dark:text-slate-300"
title_span = Component( title_span = Component(
tag_name="span", tag_name="span",
@@ -718,8 +595,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
else [] else []
), ),
) )
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
stats_row = Div( stats_row = Div(
[("class", "flex gap-4 dark:text-slate-400 mb-3")], [("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(), game.playtime_formatted(),
), ),
_stat_popover( _stat_popover(
"popover-sessions", "Number of sessions", "sessions", session_count "popover-sessions",
"Number of sessions",
"sessions",
metrics["session_count"],
), ),
_stat_popover( _stat_popover(
"popover-average", "popover-average",
"Average playtime per session", "Average playtime per session",
"average", "average",
session_average_without_manual, metrics["session_average_without_manual"],
), ),
_stat_popover( _stat_popover(
"popover-playrange", "popover-playrange",
"Earliest and latest dates played", "Earliest and latest dates played",
"playrange", "playrange",
playrange, metrics["playrange"],
), ),
], ],
) )
metadata = Div( metadata = Div(
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")], [("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)], 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), _played_row(game, request),
_meta_row( _meta_row(
"Platform", "Platform",
@@ -770,36 +651,144 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
), ),
], ],
) )
return Div(
game_info = Div(
[("id", "game-info"), ("class", "mb-10")], [("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( def _purchases_section(game: Game) -> SafeText:
page_number, on_each_side=1, on_ends=1 purchases = game.purchases.order_by("date_purchased")
) rows = [
if session_page_obj and session_count > 5 [
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 else None
) )
purchases_table = SimpleTable( header_action = Div(
columns=purchase_data["columns"], rows=purchase_data["rows"] 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( rows = [
columns=session_data["columns"], [
rows=session_data["rows"], NameWithIcon(session=session),
header_action=session_data["header_action"], f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
page_obj=session_page_obj, session.duration_formatted_with_mark(),
elided_page_range=session_elided_page_range, 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, request=request,
) )
playevents_table = SimpleTable( return _game_section("Sessions", session_count, table, "No sessions yet.")
columns=playevent_data["columns"], rows=playevent_data["rows"]
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"), ("class", "mb-6"),
("id", "history-container"), ("id", "history-container"),
@@ -809,36 +798,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
("hx-swap", "outerHTML"), ("hx-swap", "outerHTML"),
], ],
[ [
H1(children=["History"], badge=statuschange_count), H1(children=["History"], badge=statuschanges.count()),
_game_history(statuschanges), _game_history(statuschanges),
], ],
) )
_GET_SESSION_COUNT_SCRIPT = mark_safe(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
'.textContent.match("[0-9]+");\n'
" }\n"
" </script>"
)
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
content = Div( content = Div(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")], [("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
[ [
game_info, _game_header(game, request, _game_overview_metrics(game)),
_game_section( _purchases_section(game),
"Purchases", purchase_count, purchases_table, "No purchases yet." _sessions_section(game, request),
), _playevents_section(game),
_game_section( _history_section(game),
"Sessions", session_count, sessions_table, "No sessions yet." _GET_SESSION_COUNT_SCRIPT,
),
_game_section(
"Play Events", playevent_count, playevents_table, "No play events yet."
),
history,
mark_safe(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
'.textContent.match("[0-9]+");\n'
" }\n"
" </script>"
),
], ],
) )
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render_page( return render_page(
request, request,
+6 -470
View File
@@ -3,19 +3,9 @@ from typing import Any, Callable
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import ( from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F, F,
Max,
OuterRef,
Prefetch,
Q,
Subquery,
Sum, Sum,
fields,
) )
from django.db.models.functions import TruncDate, TruncMonth
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
@@ -23,10 +13,10 @@ from django.urls import reverse
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from common.layout import render_page from common.layout import render_page
from common.time import available_stats_year_range, dateformat, format_duration from common.time import format_duration
from common.utils import safe_division
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats
def model_counts(request: HttpRequest) -> dict[str, bool]: def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -75,210 +65,9 @@ def use_custom_redirect(
@login_required @login_required
def stats_alltime(request: HttpRequest) -> HttpResponse: 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 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 @login_required
@@ -290,262 +79,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
) )
if year == 0: if year == 0:
return HttpResponseRedirect(reverse("games:stats_alltime")) 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 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 @login_required
+5 -1
View File
@@ -100,6 +100,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
filter_json = request.GET.get("filter", "") filter_json = request.GET.get("filter", "")
if filter_json: if filter_json:
from games.filters import parse_purchase_filter from games.filters import parse_purchase_filter
pf = parse_purchase_filter(filter_json) pf = parse_purchase_filter(filter_json)
if pf is not None: if pf is not None:
purchases = purchases.filter(pf.to_q()) purchases = purchases.filter(pf.to_q())
@@ -129,6 +130,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
request=request, request=request,
) )
from common.components import PurchaseFilterBar, ModuleScript from common.components import PurchaseFilterBar, ModuleScript
filter_bar = PurchaseFilterBar( filter_bar = PurchaseFilterBar(
filter_json=filter_json, filter_json=filter_json,
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
@@ -139,7 +141,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
request, request,
content, content,
title="Manage purchases", 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"),
) )
+5 -1
View File
@@ -45,6 +45,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
filter_json = request.GET.get("filter", "") filter_json = request.GET.get("filter", "")
if filter_json: if filter_json:
from games.filters import parse_session_filter from games.filters import parse_session_filter
session_filter = parse_session_filter(filter_json) session_filter = parse_session_filter(filter_json)
if session_filter is not None: if session_filter is not None:
sessions = sessions.filter(session_filter.to_q()) sessions = sessions.filter(session_filter.to_q())
@@ -168,6 +169,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
request=request, request=request,
) )
from common.components import SessionFilterBar from common.components import SessionFilterBar
filter_json = request.GET.get("filter", "") filter_json = request.GET.get("filter", "")
filter_bar = SessionFilterBar( filter_bar = SessionFilterBar(
filter_json=filter_json, filter_json=filter_json,
@@ -179,7 +181,9 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
request, request,
content, content,
title="Manage sessions", 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"),
) )
+354
View File
@@ -0,0 +1,354 @@
"""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
+7 -7
View File
@@ -36,9 +36,7 @@ class ComponentCacheTest(unittest.TestCase):
self.assertGreaterEqual(info.hits, 1) # served from cache self.assertGreaterEqual(info.hits, 1) # served from cache
def test_cache_is_bounded(self): def test_cache_is_bounded(self):
self.assertEqual( self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
components._render_element.cache_parameters()["maxsize"], 4096
)
def test_safe_and_unsafe_children_do_not_collide(self): def test_safe_and_unsafe_children_do_not_collide(self):
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must """A SafeText "<b>" and a plain "<b>" are equal as strings but must
@@ -207,7 +205,9 @@ class ComponentReturnTypeTest(unittest.TestCase):
def test_a_url_name_reversed(self): def test_a_url_name_reversed(self):
from unittest.mock import patch from unittest.mock import patch
with patch("common.components.reverse", return_value="/resolved/url"): with patch(
"common.components.primitives.reverse", return_value="/resolved/url"
):
result = components.A([], "link", url_name="some_name") result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result) self.assertIn('href="/resolved/url"', result)
@@ -666,7 +666,7 @@ class ResolveNameWithIconTest(unittest.TestCase):
override_game.name = "Override" override_game.name = "Override"
override_game.platform = self.mock_platform override_game.platform = self.mock_platform
override_game.pk = 99 override_game.pk = 99
with patch("common.components.reverse", return_value="/game/99"): with patch("common.components.domain.reverse", return_value="/game/99"):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = (
components._resolve_name_with_icon( components._resolve_name_with_icon(
"", override_game, self.mock_session, True "", override_game, self.mock_session, True
@@ -676,7 +676,7 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.assertIsNot(name, "Override") self.assertIsNot(name, "Override")
def test_game_only_provides_platform(self): def test_game_only_provides_platform(self):
with patch("common.components.reverse", return_value="/game/1"): with patch("common.components.domain.reverse", return_value="/game/1"):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, True) components._resolve_name_with_icon("", self.mock_game, None, True)
) )
@@ -713,7 +713,7 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.assertEqual(link, "") self.assertEqual(link, "")
def test_linkify_true_creates_link(self): def test_linkify_true_creates_link(self):
with patch("common.components.reverse", return_value="/game/42"): with patch("common.components.domain.reverse", return_value="/game/42"):
name, platform, emulated, create_link, link = ( name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, True) components._resolve_name_with_icon("", self.mock_game, None, True)
) )
+109
View File
@@ -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 = ["&lt;div", "&lt;span", "&lt;button", "&lt;input", "&lt;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("&quot;status&quot;", html)
self.assertNotIn("&amp;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)
+27 -10
View File
@@ -10,11 +10,10 @@ from common.criteria import (
ChoiceCriterion, ChoiceCriterion,
IntCriterion, IntCriterion,
Modifier, Modifier,
MultiCriterion,
StringCriterion, StringCriterion,
) )
from common.components import FilterBar, SelectableFilter from common.components import FilterBar
from games.filters import GameFilter, parse_game_filter from games.filters import GameFilter
class TestStringCriterion: class TestStringCriterion:
@@ -30,7 +29,9 @@ class TestStringCriterion:
class TestIntCriterion: class TestIntCriterion:
def test_between(self): def test_between(self):
c = IntCriterion(value=2020, value2=2024, modifier=Modifier.BETWEEN) 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: class TestBoolCriterion:
@@ -67,7 +68,9 @@ class TestChoiceCriterion:
assert q == Q(status__in=["f"]) & ~Q(status__in=["a"]) assert q == Q(status__in=["f"]) & ~Q(status__in=["a"])
def test_include_two_and_exclude_one(self): 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") q = c.to_q("status")
assert q == Q(status__in=["f", "p"]) & ~Q(status__in=["a"]) assert q == Q(status__in=["f", "p"]) & ~Q(status__in=["a"])
@@ -105,6 +108,7 @@ class TestChoiceCriterionAgainstDB:
def _seed_games(self): def _seed_games(self):
"""Create test games with different statuses.""" """Create test games with different statuses."""
from games.models import Game, Platform from games.models import Game, Platform
platform, _ = Platform.objects.get_or_create(name="Test", icon="test") platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
statuses = ["u", "p", "f", "r", "a"] statuses = ["u", "p", "f", "r", "a"]
for i, s in enumerate(statuses): for i, s in enumerate(statuses):
@@ -115,11 +119,15 @@ class TestChoiceCriterionAgainstDB:
def _count(self, c: ChoiceCriterion) -> int: def _count(self, c: ChoiceCriterion) -> int:
from games.models import Game from games.models import Game
return Game.objects.filter(c.to_q("status")).count() return Game.objects.filter(c.to_q("status")).count()
def _statuses(self, c: ChoiceCriterion) -> set[str]: def _statuses(self, c: ChoiceCriterion) -> set[str]:
from games.models import Game 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 @pytest.mark.django_db
def test_include_finished_includes_only_finished(self): def test_include_finished_includes_only_finished(self):
@@ -138,7 +146,9 @@ class TestChoiceCriterionAgainstDB:
def test_include_and_exclude(self): def test_include_and_exclude(self):
"""Include Finished but exclude Abandoned.""" """Include Finished but exclude Abandoned."""
self._seed_games() 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 # Include f and a, but exclude a → only f
assert self._statuses(c) == {"f"} assert self._statuses(c) == {"f"}
@@ -198,7 +208,10 @@ class TestGameFilterFromJson:
assert gf.platform.value == ["1", "3"] assert gf.platform.value == ["1", "3"]
def test_round_trip(self): 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) gf = GameFilter.from_json(data)
json_out = gf.to_json() json_out = gf.to_json()
gf2 = GameFilter.from_json(json_out) gf2 = GameFilter.from_json(json_out)
@@ -236,7 +249,9 @@ class TestFilterBarRendering:
html = str( html = str(
FilterBar( FilterBar(
platform_options=[], 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 assert 'checked="true"' in html
@@ -245,7 +260,9 @@ class TestFilterBarRendering:
html = str( html = str(
FilterBar( FilterBar(
platform_options=[], 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 assert 'data-value="f"' in html
+4 -6
View File
@@ -18,9 +18,7 @@ class MiddlewareIntegrationTest(TestCase):
@staticmethod @staticmethod
def _create_user(): def _create_user():
return User.objects.create_user( return User.objects.create_user(username="testuser", password="testpass123")
username="testuser", password="testpass123"
)
def setUp(self): def setUp(self):
self.client = Client() self.client = Client()
@@ -97,10 +95,10 @@ class MiddlewareIntegrationTest(TestCase):
self.assertEqual(data["show-toast"]["message"], "Purchase refunded") self.assertEqual(data["show-toast"]["message"], "Purchase refunded")
# Verify the row HTML contains the updated row id # Verify the row HTML contains the updated row id
body = response.content.decode() 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 # Verify OoO modal close element
self.assertIn('hx-swap-oob', body) self.assertIn("hx-swap-oob", body)
self.assertIn('refund-confirmation-modal', body) self.assertIn("refund-confirmation-modal", body)
# Verify the purchase is actually refunded # Verify the purchase is actually refunded
purchase.refresh_from_db() purchase.refresh_from_db()
self.assertIsNotNone(purchase.date_refunded) self.assertIsNotNone(purchase.date_refunded)
+1 -3
View File
@@ -16,9 +16,7 @@ class FormatDurationTest(TestCase):
def test_duration_format(self): def test_duration_format(self):
g = Game(name="The Test Game") g = Game(name="The Test Game")
g.save() g.save()
p = Purchase( p = Purchase(date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO))
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save() p.save()
p.games.add(g) p.games.add(g)
p.save() p.save()
+120
View File
@@ -0,0 +1,120 @@
"""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)
+6 -5
View File
@@ -5,7 +5,6 @@ from common.time import daterange, streak_bruteforce
class StreakTest(unittest.TestCase): class StreakTest(unittest.TestCase):
def test_daterange_exclusive(self): def test_daterange_exclusive(self):
d = daterange(date(2024, 8, 1), date(2024, 8, 3)) d = daterange(date(2024, 8, 1), date(2024, 8, 3))
self.assertEqual( self.assertEqual(
@@ -24,13 +23,15 @@ class StreakTest(unittest.TestCase):
self.assertEqual(streak_bruteforce([date(2024, 8, 1)])["days"], 1) self.assertEqual(streak_bruteforce([date(2024, 8, 1)])["days"], 1)
def test_2day_streak(self): 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): def test_31day_streak(self):
self.assertEqual( self.assertEqual(
streak_bruteforce(daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True))[ streak_bruteforce(
"days" daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True)
], )["days"],
31, 31,
) )