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
def for_strings(cls) -> list[Self]:
return [
cls.EQUALS, cls.NOT_EQUALS,
cls.INCLUDES, cls.EXCLUDES,
cls.MATCHES_REGEX, cls.NOT_MATCHES_REGEX,
cls.IS_NULL, cls.NOT_NULL,
cls.EQUALS,
cls.NOT_EQUALS,
cls.INCLUDES,
cls.EXCLUDES,
cls.MATCHES_REGEX,
cls.NOT_MATCHES_REGEX,
cls.IS_NULL,
cls.NOT_NULL,
]
@classmethod
def for_numbers(cls) -> list[Self]:
return [
cls.EQUALS, cls.NOT_EQUALS,
cls.GREATER_THAN, cls.LESS_THAN,
cls.BETWEEN, cls.NOT_BETWEEN,
cls.IS_NULL, cls.NOT_NULL,
cls.EQUALS,
cls.NOT_EQUALS,
cls.GREATER_THAN,
cls.LESS_THAN,
cls.BETWEEN,
cls.NOT_BETWEEN,
cls.IS_NULL,
cls.NOT_NULL,
]
@classmethod
@@ -60,9 +68,11 @@ class Modifier(str, Enum):
@classmethod
def for_multi(cls) -> list[Self]:
return [
cls.INCLUDES, cls.EXCLUDES,
cls.INCLUDES,
cls.EXCLUDES,
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 self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2)})
return Q(
**{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
@@ -185,8 +199,12 @@ class FloatCriterion(_Criterion):
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2)})
return Q(
**{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
@@ -218,12 +236,15 @@ class DateCriterion(_Criterion):
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(**{f"{field_name}__gte": self.value,
f"{field_name}__lte": self.value2})
return Q(
**{f"{field_name}__gte": self.value, f"{field_name}__lte": self.value2}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
return Q(**{f"{field_name}__lt": self.value}) | Q(**{f"{field_name}__gt": self.value2})
return Q(**{f"{field_name}__lt": self.value}) | Q(
**{f"{field_name}__gt": self.value2}
)
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
@@ -248,6 +269,7 @@ class BoolCriterion(_Criterion):
@dataclass
class MultiCriterion(_Criterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
@@ -407,9 +429,13 @@ class OperatorFilter:
f_type = f_type.split("|")[0].strip()
if isinstance(f_type, str) and f_type in criterion_types:
criterion_cls = criterion_types[f_type]
kwargs[f.name] = criterion_cls.from_json(raw) if isinstance(raw, dict) else None
kwargs[f.name] = (
criterion_cls.from_json(raw) if isinstance(raw, dict) else None
)
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
kwargs[f.name] = f_type.from_json(raw) if isinstance(raw, dict) else None
kwargs[f.name] = (
f_type.from_json(raw) if isinstance(raw, dict) else None
)
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
+1 -3
View File
@@ -1,9 +1,7 @@
import functools
from pathlib import Path
_ICON_DIR = (
Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
)
_ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
@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")
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">
<a href="{reverse('games:index')}"
<a href="{reverse("games:index")}"
class="flex items-center space-x-3 rtl:space-x-reverse">
<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>
@@ -229,11 +229,11 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
</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">
<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_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_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_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_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_session")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
</ul>
</div>
</li>
@@ -247,20 +247,20 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
</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">
<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_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_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_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_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_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_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>
</ul>
</div>
</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>
<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>
</ul>
</div>
@@ -327,7 +327,7 @@ def Page(
" </div>\n"
f" {scripts}\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'
f" {_TOAST_CONTAINER}\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})
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
def partial_update_session_device(
request, session_id: int, payload: SessionDeviceUpdate
):
session = get_object_or_404(Session, id=session_id)
session.device_id = payload.device_id
session.save()
@@ -113,4 +115,3 @@ def partial_update_session_device(request, session_id: int, payload: SessionDevi
api.add_router("/session", session_router)
+32 -15
View File
@@ -13,6 +13,8 @@ from __future__ import annotations
from dataclasses import dataclass
from django.db.models import Q
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
@@ -65,9 +67,7 @@ class GameFilter(OperatorFilter):
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
def to_q(self) -> Q:
q = Q()
# ── individual criteria ──
@@ -118,7 +118,7 @@ class GameFilter(OperatorFilter):
return q
@staticmethod
def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
def _playtime_to_q(c: IntCriterion) -> Q:
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
@@ -127,16 +127,25 @@ class GameFilter(OperatorFilter):
from datetime import timedelta
from common.criteria import Modifier
from django.db.models import Q
m = c.modifier
field = "playtime"
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
@@ -184,11 +193,9 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
def to_q(self) -> Q:
from datetime import timedelta
from django.db.models import Q
q = Q()
if self.game is not None:
@@ -205,9 +212,19 @@ class SessionFilter(OperatorFilter):
field = "duration_total"
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
@@ -256,6 +273,7 @@ class SessionFilter(OperatorFilter):
# Cross-entity filter: sessions for games matching GameFilter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
@@ -305,9 +323,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
def to_q(self) -> Q:
q = Q()
if self.name is not None:
@@ -353,6 +369,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
+3 -1
View File
@@ -43,7 +43,9 @@ class SessionForm(forms.ModelForm):
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False)
device = forms.ModelChoiceField(
queryset=Device.objects.order_by("name"), required=False
)
mark_as_played = forms.BooleanField(
required=False,
+4 -2
View File
@@ -34,9 +34,11 @@ class HTMXMessagesMiddleware:
if "HX-Redirect" in response:
return response
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
min_level = (
message_constants.DEBUG if settings.DEBUG else message_constants.INFO
)
backend = django_messages.get_messages(request)
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
if hasattr(backend, "_set_level") and backend._get_level() > min_level:
backend._set_level(min_level)
messages = list(backend)
if not messages:
+227 -61
View File
@@ -6,99 +6,265 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Device',
name="Device",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("PC", "PC"),
("Console", "Console"),
("Handheld", "Handheld"),
("Mobile", "Mobile"),
("Single-board computer", "Single-board computer"),
("Unknown", "Unknown"),
],
default="Unknown",
max_length=255,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Platform',
name="Platform",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
('icon', models.SlugField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"group",
models.CharField(
blank=True, default=None, max_length=255, null=True
),
),
("icon", models.SlugField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='ExchangeRate',
name="ExchangeRate",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("currency_from", models.CharField(max_length=255)),
("currency_to", models.CharField(max_length=255)),
("year", models.PositiveIntegerField()),
("rate", models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
"unique_together": {("currency_from", "currency_to", "year")},
},
),
migrations.CreateModel(
name='Game',
name="Game",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
('year_released', models.IntegerField(blank=True, default=None, null=True)),
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"sort_name",
models.CharField(
blank=True, default=None, max_length=255, null=True
),
),
(
"year_released",
models.IntegerField(blank=True, default=None, null=True),
),
(
"wikidata",
models.CharField(
blank=True, default=None, max_length=50, null=True
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"platform",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.platform",
),
),
],
options={
'unique_together': {('name', 'platform', 'year_released')},
"unique_together": {("name", "platform", "year_released")},
},
),
migrations.CreateModel(
name='Purchase',
name="Purchase",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_purchased', models.DateField()),
('date_refunded', models.DateField(blank=True, null=True)),
('date_finished', models.DateField(blank=True, null=True)),
('date_dropped', models.DateField(blank=True, null=True)),
('infinite', models.BooleanField(default=False)),
('price', models.FloatField(default=0)),
('price_currency', models.CharField(default='USD', max_length=3)),
('converted_price', models.FloatField(null=True)),
('converted_currency', models.CharField(max_length=3, null=True)),
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date_purchased", models.DateField()),
("date_refunded", models.DateField(blank=True, null=True)),
("date_finished", models.DateField(blank=True, null=True)),
("date_dropped", models.DateField(blank=True, null=True)),
("infinite", models.BooleanField(default=False)),
("price", models.FloatField(default=0)),
("price_currency", models.CharField(default="USD", max_length=3)),
("converted_price", models.FloatField(null=True)),
("converted_currency", models.CharField(max_length=3, null=True)),
(
"ownership_type",
models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
(
"type",
models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
(
"name",
models.CharField(blank=True, default="", max_length=255, null=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"games",
models.ManyToManyField(
blank=True, related_name="purchases", to="games.game"
),
),
(
"platform",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
(
"related_purchase",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
],
),
migrations.CreateModel(
name='Session',
name="Session",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp_start', models.DateTimeField()),
('timestamp_end', models.DateTimeField(blank=True, null=True)),
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
('duration_calculated', models.DurationField(blank=True, null=True)),
('note', models.TextField(blank=True, null=True)),
('emulated', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timestamp_start", models.DateTimeField()),
("timestamp_end", models.DateTimeField(blank=True, null=True)),
(
"duration_manual",
models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
("duration_calculated", models.DurationField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
("emulated", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("modified_at", models.DateTimeField(auto_now=True)),
(
"device",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.device",
),
),
(
"game",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="sessions",
to="games.game",
),
),
],
options={
'get_latest_by': 'timestamp_start',
"get_latest_by": "timestamp_start",
},
),
]
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0001_initial'),
("games", "0001_initial"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
model_name="purchase",
name="price_per_game",
field=models.FloatField(null=True),
),
]
+3 -4
View File
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0002_purchase_price_per_game'),
("games", "0002_purchase_price_per_game"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='updated_at',
model_name="purchase",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
]
@@ -5,55 +5,66 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0005_game_mastered_game_status'),
("games", "0005_game_mastered_game_status"),
]
operations = [
migrations.AlterField(
model_name='game',
name='sort_name',
field=models.CharField(blank=True, default='', max_length=255),
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AlterField(
model_name='game',
name='wikidata',
field=models.CharField(blank=True, default='', max_length=50),
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default="", max_length=50),
),
migrations.AlterField(
model_name='platform',
name='group',
field=models.CharField(blank=True, default='', max_length=255),
model_name="platform",
name="group",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='converted_currency',
field=models.CharField(blank=True, default='', max_length=3),
model_name="purchase",
name="converted_currency",
field=models.CharField(blank=True, default="", max_length=3),
),
migrations.AlterField(
model_name='purchase',
name='games',
field=models.ManyToManyField(related_name='purchases', to='games.game'),
model_name="purchase",
name="games",
field=models.ManyToManyField(related_name="purchases", to="games.game"),
),
migrations.AlterField(
model_name='purchase',
name='name',
field=models.CharField(blank=True, default='', max_length=255),
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='related_purchase',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
migrations.AlterField(
model_name='session',
name='game',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
model_name="session",
name="game",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="sessions",
to="games.game",
),
),
migrations.AlterField(
model_name='session',
name='note',
field=models.TextField(blank=True, default=''),
model_name="session",
name="note",
field=models.TextField(blank=True, default=""),
),
]
+3 -4
View File
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
]
operations = [
migrations.AddField(
model_name='game',
name='updated_at',
model_name="game",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
]
@@ -4,18 +4,17 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
("games", "0008_game_original_year_released_gamestatuschange_and_more"),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='date_dropped',
model_name="purchase",
name="date_dropped",
),
migrations.RemoveField(
model_name='purchase',
name='date_finished',
model_name="purchase",
name="date_finished",
),
]
@@ -4,14 +4,13 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'),
("games", "0009_remove_purchase_date_dropped_and_more"),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='price_per_game',
model_name="purchase",
name="price_per_game",
),
]
@@ -6,15 +6,24 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0010_remove_purchase_price_per_game'),
("games", "0010_remove_purchase_price_per_game"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
model_name="purchase",
name="price_per_game",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.CombinedExpression(
django.db.models.functions.comparison.Coalesce(
models.F("converted_price"), models.F("price"), 0
),
"/",
models.F("num_purchases"),
),
output_field=models.FloatField(),
),
),
]
@@ -5,15 +5,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0013_game_playtime'),
("games", "0013_game_playtime"),
]
operations = [
migrations.AddField(
model_name='session',
name='duration_total',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
model_name="session",
name="duration_total",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.CombinedExpression(
models.F("duration_calculated"), "+", models.F("duration_manual")
),
output_field=models.DurationField(),
),
),
]
@@ -5,35 +5,39 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0014_session_duration_total'),
("games", "0014_session_duration_total"),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='date_purchased',
field=models.DateField(verbose_name='Purchased'),
model_name="purchase",
name="date_purchased",
field=models.DateField(verbose_name="Purchased"),
),
migrations.AlterField(
model_name='purchase',
name='date_refunded',
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
model_name="purchase",
name="date_refunded",
field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
),
migrations.AlterField(
model_name='session',
name='duration_manual',
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True,
default=datetime.timedelta(0),
null=True,
verbose_name="Manual duration",
),
),
migrations.AlterField(
model_name='session',
name='timestamp_end',
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
model_name="session",
name="timestamp_end",
field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
),
migrations.AlterField(
model_name='session',
name='timestamp_start',
field=models.DateTimeField(verbose_name='Start'),
model_name="session",
name="timestamp_start",
field=models.DateTimeField(verbose_name="Start"),
),
]
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0015_alter_purchase_date_purchased_and_more'),
("games", "0015_alter_purchase_date_purchased_and_more"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='needs_price_update',
model_name="purchase",
name="needs_price_update",
field=models.BooleanField(db_index=True, default=True),
),
migrations.RunSQL(
+31 -12
View File
@@ -4,26 +4,45 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0016_add_needs_price_update'),
("games", "0016_add_needs_price_update"),
]
operations = [
migrations.CreateModel(
name='FilterPreset',
name="FilterPreset",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)),
('find_filter', models.JSONField(blank=True, default=dict)),
('object_filter', models.JSONField(blank=True, default=dict)),
('ui_options', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"mode",
models.CharField(
choices=[
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
],
default="games",
max_length=50,
),
),
("find_filter", models.JSONField(blank=True, default=dict)),
("object_filter", models.JSONField(blank=True, default=dict)),
("ui_options", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
"ordering": ["name"],
},
),
]
+4 -2
View File
@@ -66,8 +66,10 @@ class Game(models.Model):
return self.name
def finished(self):
return (self.status == self.Status.FINISHED or
self.playevents.filter(ended__isnull=False).exists())
return (
self.status == self.Status.FINISHED
or self.playevents.filter(ended__isnull=False).exists()
)
def abandoned(self):
return self.status == self.Status.ABANDONED
+3 -1
View File
@@ -60,7 +60,9 @@ def _save_converted_price(purchase, converted_price, needs_update):
purchase.converted_currency = currency_to
if needs_update:
purchase.needs_price_update = False
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
purchase.save(
update_fields=["converted_price", "converted_currency", "needs_price_update"]
)
def convert_prices():
+5 -1
View File
@@ -23,7 +23,11 @@ urlpatterns = [
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>/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/list", game.list_games, name="list_games"),
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.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from django.utils.safestring import mark_safe
from games.models import FilterPreset
@@ -21,9 +21,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
items: list[str] = []
for preset in presets:
filter_json = (
json.dumps(preset.object_filter) if preset.object_filter else ""
)
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
list_url = reverse(f"games:list_{mode}")
delete_url = reverse("games:delete_preset", args=[preset.id])
@@ -40,14 +38,9 @@ def list_presets(request: HttpRequest) -> HttpResponse:
)
if not items:
items = [
'<li class="px-4 py-2 text-sm text-body italic">'
"No saved presets</li>"
]
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
return HttpResponse(
mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')
)
return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>'))
@login_required
+172 -183
View File
@@ -148,7 +148,9 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
request,
content,
title="Manage games",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
@@ -540,36 +542,129 @@ def _game_section(
)
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
purchases = game.purchases.order_by("date_purchased")
def _game_overview_metrics(game: Game) -> dict[str, Any]:
"""Request-free header metrics: total session count, play range, and the
per-session average (excluding manually-logged sessions)."""
sessions = game.sessions
session_count = sessions.count()
session_count_without_manual = game.sessions.without_manual().count()
session_count_without_manual = sessions.without_manual().count()
if sessions.exists():
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
latest_session = sessions.latest()
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
end = local_strftime(sessions.latest().timestamp_start, "%b %Y")
playrange = start if start == end else f"{start}{end}"
else:
playrange = "N/A"
latest_session = None
total_hours_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
session_average_without_manual = round(
safe_division(total_hours_without_manual, int(session_count_without_manual)), 1
)
return {
"session_count": session_count,
"playrange": playrange,
"session_average_without_manual": session_average_without_manual,
}
purchase_data: dict[str, Any] = {
"columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
grey_value_class = "text-black dark:text-slate-300"
title_span = Component(
tag_name="span",
attributes=[("class", "text-balance max-w-120 text-4xl")],
children=[
Component(
tag_name="span",
attributes=[("class", "font-bold font-serif")],
children=[game.name],
),
]
+ (
[
mark_safe("&nbsp;"),
Popover(
popover_content="Original release year",
wrapped_classes="text-slate-500 text-2xl",
id="popover-year",
children=[str(game.year_released)],
),
]
if game.year_released
else []
),
)
stats_row = Div(
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
[
_stat_popover(
"popover-hours",
"Total hours played",
"hours",
game.playtime_formatted(),
),
_stat_popover(
"popover-sessions",
"Number of sessions",
"sessions",
metrics["session_count"],
),
_stat_popover(
"popover-average",
"Average playtime per session",
"average",
metrics["session_average_without_manual"],
),
_stat_popover(
"popover-playrange",
"Earliest and latest dates played",
"playrange",
metrics["playrange"],
),
],
)
metadata = Div(
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
[
_meta_row(
"Original year",
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.original_year_released)],
),
),
_meta_row(
"Status",
GameStatusSelector(game, Game.Status.choices, get_token(request)),
"👑" if game.mastered else "",
),
_played_row(game, request),
_meta_row(
"Platform",
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.platform)],
),
),
],
)
return Div(
[("id", "game-info"), ("class", "mb-10")],
[
Div([("class", "flex gap-5 mb-3")], [title_span]),
stats_row,
metadata,
_game_action_buttons(game),
],
)
def _purchases_section(game: Game) -> SafeText:
purchases = game.purchases.order_by("date_purchased")
rows = [
[
LinkedPurchase(purchase),
purchase.get_type_display(),
@@ -583,9 +678,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"color": "gray",
},
{
"href": reverse(
"games:delete_purchase", args=[purchase.pk]
),
"href": reverse("games:delete_purchase", args=[purchase.pk]),
"slot": Icon("delete"),
"color": "red",
},
@@ -593,30 +686,29 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
),
]
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")
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
last_session = sessions_all.latest() if sessions_all.exists() else None
session_data: dict[str, Any] = {
"header_action": Div(
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
)
header_action = Div(
children=[
A(
url_name="games:add_session",
children=Button(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
),
A(
href=reverse(
@@ -641,9 +733,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
if last_session
else "",
],
),
"columns": ["Game", "Date", "Duration", "Actions"],
"rows": [
)
rows = [
[
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
@@ -673,133 +764,31 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
]
),
]
for session in sessions
],
}
playevents = game.playevents.all()
playevent_count = playevents.count()
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
purchase_count = game.purchases.count()
status_selector_html = GameStatusSelector(
game, Game.Status.choices, get_token(request)
)
session_average_without_manual = round(
safe_division(total_hours_without_manual, int(session_count_without_manual)),
1,
)
grey_value_class = "text-black dark:text-slate-300"
title_span = Component(
tag_name="span",
attributes=[("class", "text-balance max-w-120 text-4xl")],
children=[
Component(
tag_name="span",
attributes=[("class", "font-bold font-serif")],
children=[game.name],
),
for session in page_obj.object_list
]
+ (
[
mark_safe("&nbsp;"),
Popover(
popover_content="Original release year",
wrapped_classes="text-slate-500 text-2xl",
id="popover-year",
children=[str(game.year_released)],
),
]
if game.year_released
else []
),
)
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
stats_row = Div(
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
[
_stat_popover(
"popover-hours",
"Total hours played",
"hours",
game.playtime_formatted(),
),
_stat_popover(
"popover-sessions", "Number of sessions", "sessions", session_count
),
_stat_popover(
"popover-average",
"Average playtime per session",
"average",
session_average_without_manual,
),
_stat_popover(
"popover-playrange",
"Earliest and latest dates played",
"playrange",
playrange,
),
],
)
metadata = Div(
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
[
_meta_row(
"Original year",
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.original_year_released)],
),
),
_meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
_played_row(game, request),
_meta_row(
"Platform",
Component(
tag_name="span",
attributes=[("class", grey_value_class)],
children=[str(game.platform)],
),
),
],
)
game_info = Div(
[("id", "game-info"), ("class", "mb-10")],
[title_row, stats_row, metadata, _game_action_buttons(game)],
)
session_elided_page_range = (
session_page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if session_page_obj and session_count > 5
else None
)
purchases_table = SimpleTable(
columns=purchase_data["columns"], rows=purchase_data["rows"]
)
sessions_table = SimpleTable(
columns=session_data["columns"],
rows=session_data["rows"],
header_action=session_data["header_action"],
page_obj=session_page_obj,
elided_page_range=session_elided_page_range,
table = SimpleTable(
columns=["Game", "Date", "Duration", "Actions"],
rows=rows,
header_action=header_action,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
playevents_table = SimpleTable(
columns=playevent_data["columns"], rows=playevent_data["rows"]
return _game_section("Sessions", session_count, table, "No sessions yet.")
def _playevents_section(game: Game) -> SafeText:
playevents = game.playevents.all()
data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
table = SimpleTable(columns=data["columns"], rows=data["rows"])
return _game_section(
"Play Events", playevents.count(), table, "No play events yet."
)
history = Div(
def _history_section(game: Game) -> SafeText:
statuschanges = game.status_changes.all()
return Div(
[
("class", "mb-6"),
("id", "history-container"),
@@ -809,36 +798,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
("hx-swap", "outerHTML"),
],
[
H1(children=["History"], badge=statuschange_count),
H1(children=["History"], badge=statuschanges.count()),
_game_history(statuschanges),
],
)
content = Div(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
[
game_info,
_game_section(
"Purchases", purchase_count, purchases_table, "No purchases yet."
),
_game_section(
"Sessions", session_count, sessions_table, "No sessions yet."
),
_game_section(
"Play Events", playevent_count, playevents_table, "No play events yet."
),
history,
mark_safe(
_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(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
[
_game_header(game, request, _game_overview_metrics(game)),
_purchases_section(game),
_sessions_section(game, request),
_playevents_section(game),
_history_section(game),
_GET_SESSION_COUNT_SCRIPT,
],
)
request.session["return_path"] = request.path
return render_page(
request,
+6 -470
View File
@@ -3,19 +3,9 @@ from typing import Any, Callable
from django.contrib.auth.decorators import login_required
from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F,
Max,
OuterRef,
Prefetch,
Q,
Subquery,
Sum,
fields,
)
from django.db.models.functions import TruncDate, TruncMonth
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect
@@ -23,10 +13,10 @@ from django.urls import reverse
from django.utils.timezone import now as timezone_now
from common.layout import render_page
from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division
from common.time import format_duration
from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats
def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -75,210 +65,9 @@ def use_custom_redirect(
@login_required
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("sessions"),
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
games__sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("games")
this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
this_year_purchases_refunded = Purchase.objects.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__isnull=False)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc.
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status=Game.Status.RETIRED)
& ~Q(games__status=Game.Status.ABANDONED)
)
)
this_year_purchases_dropped = (
this_year_purchases.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__isnull=False)
)
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
)
this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count()
)
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished_count,
this_year_purchases_without_refunded_count,
)
* 100
)
_finished_purchases_qs = Purchase.objects.finished()
_finished_with_date = _finished_purchases_qs.annotate(
date_finished=Subquery(
Purchase.objects.filter(pk=OuterRef("pk"))
.annotate(max_ended=Max("games__playevents__ended"))
.values("max_ended")[:1]
)
)
purchases_finished_this_year = _finished_with_date
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
"-date_finished"
)
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(sessions__in=this_year_sessions)
.distinct()
.annotate(total_playtime=Sum(F("sessions__duration_total")))
.filter(total_playtime__gt=timedelta(0))
)
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_total"))
.order_by("month")
)
for month in month_playtimes:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
total_playtime_per_platform = (
this_year_sessions.values("game__platform__name")
.annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "playtime")
.order_by("-playtime")
)
backlog_decrease_count = purchases_finished_this_year.count()
first_play_date = "N/A"
last_play_date = "N/A"
first_play_game = None
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
* 100
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_year_games": this_year_played_purchases.all().count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"dropped_count": this_year_purchases_dropped_count,
"dropped_percentage": this_year_purchases_dropped_percentage,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year_count,
all_purchased_this_year_count,
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count,
"longest_session_time": (
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
if longest_session
else 0
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
else 0
),
"highest_session_count_game": (
game_highest_session_count if game_highest_session_count else None
),
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game,
"first_play_game": first_play_game,
"first_play_date": first_play_date,
"last_play_game": last_play_game,
"last_play_date": last_play_date,
"title": f"{year} Stats",
"stats_dropdown_year_range": available_stats_year_range(),
}
request.session["return_path"] = request.path
return render_page(request, stats_content(context), title=context["title"])
data = compute_stats(None)
return render_page(request, stats_content(data), title=data["title"])
@login_required
@@ -290,262 +79,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
)
if year == 0:
return HttpResponseRedirect(reverse("games:stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).prefetch_related("game")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"sessions",
filter=Q(sessions__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
games__sessions__in=this_year_sessions
).distinct()
this_year_played_games = Game.objects.filter(
sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(
date_purchased__year=year
).prefetch_related("games")
# purchased this year
# not refunded
this_year_purchases_without_refunded = Purchase.objects.filter(
date_refunded=None, date_purchased__year=year
)
# purchased this year
# not refunded
# not finished
# not infinite
# only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__year=year)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
)
# unfinished = not finished AND not dropped
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status=Game.Status.RETIRED)
& ~Q(games__status=Game.Status.ABANDONED)
)
)
# dropped = abandoned OR retired OR refunded (OR logic for transition)
this_year_purchases_dropped = (
this_year_purchases.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__year=year)
)
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
)
this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count()
)
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished_count,
this_year_purchases_without_refunded_count,
)
* 100
)
purchases_finished_this_year = (
Purchase.objects.finished()
.filter(games__playevents__ended__year=year)
.annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(games__year_released=year).order_by(
"games__playevents__ended"
)
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(
games__playevents__ended__year=year
).annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
).order_by("games__playevents__ended")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(sessions__timestamp_start__year=year)
.annotate(
total_playtime=Sum(
F("sessions__duration_calculated"),
)
)
.filter(total_playtime__gt=timedelta(0))
)
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_total"))
.order_by("month")
)
highest_session_average_game = (
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
total_playtime_per_platform = (
this_year_sessions.values("game__platform__name")
.annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "playtime")
.order_by("-playtime")
)
backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year)
.filter(games__status=Game.Status.FINISHED)
.filter(games__playevents__ended__year=year)
.count()
)
first_play_date = "N/A"
last_play_date = "N/A"
first_play_game = None
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases.count()
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
date_purchased__year=year
)
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
* 100
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": this_year_played_games.count(),
"total_year_games": this_year_played_purchases.filter(
games__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
"games"
).order_by("games__playevents__ended"),
"all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"games"
).order_by("games__playevents__ended"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"games"
).order_by("games__playevents__ended"),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"dropped_count": this_year_purchases_dropped_count,
"dropped_percentage": this_year_purchases_dropped_percentage,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year_count,
all_purchased_this_year_count,
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
"all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count,
"longest_session_time": (
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
if longest_session
else 0
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
else 0
),
"highest_session_count_game": (
game_highest_session_count if game_highest_session_count else None
),
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game,
"first_play_game": first_play_game,
"first_play_date": first_play_date,
"last_play_game": last_play_game,
"last_play_date": last_play_date,
"title": f"{year} Stats",
"month_playtimes": month_playtimes,
"stats_dropdown_year_range": available_stats_year_range(),
}
request.session["return_path"] = request.path
return render_page(request, stats_content(context), title=context["title"])
data = compute_stats(year)
return render_page(request, stats_content(data), title=data["title"])
@login_required
+5 -1
View File
@@ -100,6 +100,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
filter_json = request.GET.get("filter", "")
if filter_json:
from games.filters import parse_purchase_filter
pf = parse_purchase_filter(filter_json)
if pf is not None:
purchases = purchases.filter(pf.to_q())
@@ -129,6 +130,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
request=request,
)
from common.components import PurchaseFilterBar, ModuleScript
filter_bar = PurchaseFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
@@ -139,7 +141,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
request,
content,
title="Manage purchases",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
+5 -1
View File
@@ -45,6 +45,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
filter_json = request.GET.get("filter", "")
if filter_json:
from games.filters import parse_session_filter
session_filter = parse_session_filter(filter_json)
if session_filter is not None:
sessions = sessions.filter(session_filter.to_q())
@@ -168,6 +169,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
request=request,
)
from common.components import SessionFilterBar
filter_json = request.GET.get("filter", "")
filter_bar = SessionFilterBar(
filter_json=filter_json,
@@ -179,7 +181,9 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
request,
content,
title="Manage sessions",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
+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
def test_cache_is_bounded(self):
self.assertEqual(
components._render_element.cache_parameters()["maxsize"], 4096
)
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
def test_safe_and_unsafe_children_do_not_collide(self):
"""A SafeText "<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):
from unittest.mock import patch
with patch("common.components.reverse", return_value="/resolved/url"):
with patch(
"common.components.primitives.reverse", return_value="/resolved/url"
):
result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result)
@@ -666,7 +666,7 @@ class ResolveNameWithIconTest(unittest.TestCase):
override_game.name = "Override"
override_game.platform = self.mock_platform
override_game.pk = 99
with patch("common.components.reverse", return_value="/game/99"):
with patch("common.components.domain.reverse", return_value="/game/99"):
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon(
"", override_game, self.mock_session, True
@@ -676,7 +676,7 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.assertIsNot(name, "Override")
def test_game_only_provides_platform(self):
with patch("common.components.reverse", return_value="/game/1"):
with patch("common.components.domain.reverse", return_value="/game/1"):
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, True)
)
@@ -713,7 +713,7 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.assertEqual(link, "")
def test_linkify_true_creates_link(self):
with patch("common.components.reverse", return_value="/game/42"):
with patch("common.components.domain.reverse", return_value="/game/42"):
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, True)
)
+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,
IntCriterion,
Modifier,
MultiCriterion,
StringCriterion,
)
from common.components import FilterBar, SelectableFilter
from games.filters import GameFilter, parse_game_filter
from common.components import FilterBar
from games.filters import GameFilter
class TestStringCriterion:
@@ -30,7 +29,9 @@ class TestStringCriterion:
class TestIntCriterion:
def test_between(self):
c = IntCriterion(value=2020, value2=2024, modifier=Modifier.BETWEEN)
assert c.to_q("year_released") == Q(year_released__gte=2020, year_released__lte=2024)
assert c.to_q("year_released") == Q(
year_released__gte=2020, year_released__lte=2024
)
class TestBoolCriterion:
@@ -67,7 +68,9 @@ class TestChoiceCriterion:
assert q == Q(status__in=["f"]) & ~Q(status__in=["a"])
def test_include_two_and_exclude_one(self):
c = ChoiceCriterion(value=["f", "p"], excludes=["a"], modifier=Modifier.INCLUDES)
c = ChoiceCriterion(
value=["f", "p"], excludes=["a"], modifier=Modifier.INCLUDES
)
q = c.to_q("status")
assert q == Q(status__in=["f", "p"]) & ~Q(status__in=["a"])
@@ -105,6 +108,7 @@ class TestChoiceCriterionAgainstDB:
def _seed_games(self):
"""Create test games with different statuses."""
from games.models import Game, Platform
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
statuses = ["u", "p", "f", "r", "a"]
for i, s in enumerate(statuses):
@@ -115,11 +119,15 @@ class TestChoiceCriterionAgainstDB:
def _count(self, c: ChoiceCriterion) -> int:
from games.models import Game
return Game.objects.filter(c.to_q("status")).count()
def _statuses(self, c: ChoiceCriterion) -> set[str]:
from games.models import Game
return set(Game.objects.filter(c.to_q("status")).values_list("status", flat=True))
return set(
Game.objects.filter(c.to_q("status")).values_list("status", flat=True)
)
@pytest.mark.django_db
def test_include_finished_includes_only_finished(self):
@@ -138,7 +146,9 @@ class TestChoiceCriterionAgainstDB:
def test_include_and_exclude(self):
"""Include Finished but exclude Abandoned."""
self._seed_games()
c = ChoiceCriterion(value=["f", "a"], excludes=["a"], modifier=Modifier.INCLUDES)
c = ChoiceCriterion(
value=["f", "a"], excludes=["a"], modifier=Modifier.INCLUDES
)
# Include f and a, but exclude a → only f
assert self._statuses(c) == {"f"}
@@ -198,7 +208,10 @@ class TestGameFilterFromJson:
assert gf.platform.value == ["1", "3"]
def test_round_trip(self):
data = {"status": {"value": ["f"], "modifier": "INCLUDES"}, "mastered": {"value": True, "modifier": "EQUALS"}}
data = {
"status": {"value": ["f"], "modifier": "INCLUDES"},
"mastered": {"value": True, "modifier": "EQUALS"},
}
gf = GameFilter.from_json(data)
json_out = gf.to_json()
gf2 = GameFilter.from_json(json_out)
@@ -236,7 +249,9 @@ class TestFilterBarRendering:
html = str(
FilterBar(
platform_options=[],
filter_json=json.dumps({"mastered": {"value": True, "modifier": "EQUALS"}}),
filter_json=json.dumps(
{"mastered": {"value": True, "modifier": "EQUALS"}}
),
)
)
assert 'checked="true"' in html
@@ -245,7 +260,9 @@ class TestFilterBarRendering:
html = str(
FilterBar(
platform_options=[],
filter_json=json.dumps({"status": {"value": ["f"], "modifier": "INCLUDES"}}),
filter_json=json.dumps(
{"status": {"value": ["f"], "modifier": "INCLUDES"}}
),
)
)
assert 'data-value="f"' in html
+4 -6
View File
@@ -18,9 +18,7 @@ class MiddlewareIntegrationTest(TestCase):
@staticmethod
def _create_user():
return User.objects.create_user(
username="testuser", password="testpass123"
)
return User.objects.create_user(username="testuser", password="testpass123")
def setUp(self):
self.client = Client()
@@ -97,10 +95,10 @@ class MiddlewareIntegrationTest(TestCase):
self.assertEqual(data["show-toast"]["message"], "Purchase refunded")
# Verify the row HTML contains the updated row id
body = response.content.decode()
self.assertIn(f'purchase-row-{purchase.id}', body)
self.assertIn(f"purchase-row-{purchase.id}", body)
# Verify OoO modal close element
self.assertIn('hx-swap-oob', body)
self.assertIn('refund-confirmation-modal', body)
self.assertIn("hx-swap-oob", body)
self.assertIn("refund-confirmation-modal", body)
# Verify the purchase is actually refunded
purchase.refresh_from_db()
self.assertIsNotNone(purchase.date_refunded)
+1 -3
View File
@@ -16,9 +16,7 @@ class FormatDurationTest(TestCase):
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
p = Purchase(
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p = Purchase(date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO))
p.save()
p.games.add(g)
p.save()
+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):
def test_daterange_exclusive(self):
d = daterange(date(2024, 8, 1), date(2024, 8, 3))
self.assertEqual(
@@ -24,13 +23,15 @@ class StreakTest(unittest.TestCase):
self.assertEqual(streak_bruteforce([date(2024, 8, 1)])["days"], 1)
def test_2day_streak(self):
self.assertEqual(streak_bruteforce([date(2024, 8, 1), date(2024, 8, 2)])["days"], 2)
self.assertEqual(
streak_bruteforce([date(2024, 8, 1), date(2024, 8, 2)])["days"], 2
)
def test_31day_streak(self):
self.assertEqual(
streak_bruteforce(daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True))[
"days"
],
streak_bruteforce(
daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True)
)["days"],
31,
)