Fix more code smells
Django CI/CD / test (push) Successful in 39s
Django CI/CD / build-and-push (push) Successful in 1m19s

This commit is contained in:
2026-06-06 13:14:55 +02:00
parent f4161bf3f4
commit ed8589a972
44 changed files with 4898 additions and 3164 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")
+1950
View File
File diff suppressed because it is too large Load Diff
-2633
View File
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)
+55 -38
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,
@@ -32,11 +34,11 @@ from common.criteria import (
class FindFilter:
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
q: str | None = None # free-text search
q: str | None = None # free-text search
page: int = 1
per_page: int = 25
sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc
sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc
# ── GameFilter ─────────────────────────────────────────────────────────────
@@ -55,19 +57,17 @@ class GameFilter(OperatorFilter):
year_released: IntCriterion | None = None
original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget
status: ChoiceCriterion | None = None # selectable filter widget
platform: ChoiceCriterion | None = None # selectable filter widget
status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
def to_q(self) -> Q:
q = Q()
# ── individual criteria ──
@@ -118,7 +118,7 @@ class GameFilter(OperatorFilter):
return q
@staticmethod
def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
def _playtime_to_q(c: IntCriterion) -> Q:
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
@@ -127,16 +127,25 @@ class GameFilter(OperatorFilter):
from datetime import timedelta
from common.criteria import Modifier
from django.db.models import Q
m = c.modifier
field = "playtime"
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
@@ -167,15 +176,15 @@ class SessionFilter(OperatorFilter):
OR: SessionFilter | None = None
NOT: SessionFilter | None = None
game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id
game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0
duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0
created_at: StringCriterion | None = None
# Free-text search
@@ -184,11 +193,9 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
def to_q(self) -> Q:
from datetime import timedelta
from django.db.models import Q
q = Q()
if self.game is not None:
@@ -205,9 +212,19 @@ class SessionFilter(OperatorFilter):
field = "duration_total"
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
@@ -256,6 +273,7 @@ class SessionFilter(OperatorFilter):
# Cross-entity filter: sessions for games matching GameFilter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
@@ -285,17 +303,17 @@ class PurchaseFilter(OperatorFilter):
NOT: PurchaseFilter | None = None
name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None
price_currency: StringCriterion | None = None
num_purchases: IntCriterion | None = None
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None
@@ -305,9 +323,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
def to_q(self) -> Q:
q = Q()
if self.name is not None:
@@ -353,6 +369,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
+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():
+1 -1
View File
@@ -9,5 +9,5 @@ register = template.Library()
def randomid(seed: str = "") -> str:
content_hash = hashlib.sha1(seed.encode()).hexdigest()
if seed:
return content_hash[:max(0, 10 - len(seed))] + seed
return content_hash[: max(0, 10 - len(seed))] + seed
return content_hash[:10]
+6 -2
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"),
@@ -175,4 +179,4 @@ urlpatterns = [
filter_presets.load_preset,
name="load_preset",
),
]
]
+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
+177 -188
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,159 +542,34 @@ def _game_section(
)
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
purchases = game.purchases.order_by("date_purchased")
def _game_overview_metrics(game: Game) -> dict[str, Any]:
"""Request-free header metrics: total session count, play range, and the
per-session average (excluding manually-logged sessions)."""
sessions = game.sessions
session_count = sessions.count()
session_count_without_manual = game.sessions.without_manual().count()
session_count_without_manual = sessions.without_manual().count()
if sessions.exists():
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
latest_session = sessions.latest()
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
end = local_strftime(sessions.latest().timestamp_start, "%b %Y")
playrange = start if start == end else f"{start}{end}"
else:
playrange = "N/A"
latest_session = None
total_hours_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
purchase_data: dict[str, Any] = {
"columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [
[
LinkedPurchase(purchase),
purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat),
PurchasePrice(purchase),
ButtonGroup(
[
{
"href": reverse("games:edit_purchase", args=[purchase.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse(
"games:delete_purchase", args=[purchase.pk]
),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for purchase in purchases
],
}
sessions_all = game.sessions.order_by("-timestamp_start")
last_session = None
if sessions_all.exists():
last_session = sessions_all.latest()
session_count = sessions_all.count()
session_paginator = Paginator(sessions_all, 5)
page_number = request.GET.get("page", 1)
session_page_obj = session_paginator.get_page(page_number)
sessions = session_page_obj.object_list
session_data: dict[str, Any] = {
"header_action": Div(
children=[
A(
url_name="games:add_session",
children=Button(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.game.name}"),
],
)
],
),
)
if last_session
else "",
],
),
"columns": ["Game", "Date", "Duration", "Actions"],
"rows": [
[
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
ButtonGroup(
[
{
"href": reverse(
"games:list_sessions_end_session", args=[session.pk]
),
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_session", args=[session.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for session in sessions
],
}
playevents = game.playevents.all()
playevent_count = playevents.count()
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
purchase_count = game.purchases.count()
status_selector_html = GameStatusSelector(
game, Game.Status.choices, get_token(request)
)
session_average_without_manual = round(
safe_division(total_hours_without_manual, int(session_count_without_manual)),
1,
safe_division(total_hours_without_manual, int(session_count_without_manual)), 1
)
return {
"session_count": session_count,
"playrange": playrange,
"session_average_without_manual": session_average_without_manual,
}
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
grey_value_class = "text-black dark:text-slate-300"
title_span = Component(
tag_name="span",
@@ -718,8 +595,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
else []
),
)
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
stats_row = Div(
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
[
@@ -730,23 +605,25 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game.playtime_formatted(),
),
_stat_popover(
"popover-sessions", "Number of sessions", "sessions", session_count
"popover-sessions",
"Number of sessions",
"sessions",
metrics["session_count"],
),
_stat_popover(
"popover-average",
"Average playtime per session",
"average",
session_average_without_manual,
metrics["session_average_without_manual"],
),
_stat_popover(
"popover-playrange",
"Earliest and latest dates played",
"playrange",
playrange,
metrics["playrange"],
),
],
)
metadata = Div(
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
[
@@ -758,7 +635,11 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
children=[str(game.original_year_released)],
),
),
_meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
_meta_row(
"Status",
GameStatusSelector(game, Game.Status.choices, get_token(request)),
"👑" if game.mastered else "",
),
_played_row(game, request),
_meta_row(
"Platform",
@@ -770,36 +651,144 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
),
],
)
game_info = Div(
return Div(
[("id", "game-info"), ("class", "mb-10")],
[title_row, stats_row, metadata, _game_action_buttons(game)],
[
Div([("class", "flex gap-5 mb-3")], [title_span]),
stats_row,
metadata,
_game_action_buttons(game),
],
)
session_elided_page_range = (
session_page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if session_page_obj and session_count > 5
def _purchases_section(game: Game) -> SafeText:
purchases = game.purchases.order_by("date_purchased")
rows = [
[
LinkedPurchase(purchase),
purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat),
PurchasePrice(purchase),
ButtonGroup(
[
{
"href": reverse("games:edit_purchase", args=[purchase.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_purchase", args=[purchase.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for purchase in purchases
]
table = SimpleTable(columns=["Name", "Type", "Date", "Price", "Actions"], rows=rows)
return _game_section("Purchases", purchases.count(), table, "No purchases yet.")
def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
sessions_all = game.sessions.order_by("-timestamp_start")
session_count = sessions_all.count()
last_session = sessions_all.latest() if sessions_all.exists() else None
page_number = request.GET.get("page", 1)
page_obj = Paginator(sessions_all, 5).get_page(page_number)
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if session_count > 5
else None
)
purchases_table = SimpleTable(
columns=purchase_data["columns"], rows=purchase_data["rows"]
header_action = Div(
children=[
A(
url_name="games:add_session",
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.game.name}"),
],
)
],
),
)
if last_session
else "",
],
)
sessions_table = SimpleTable(
columns=session_data["columns"],
rows=session_data["rows"],
header_action=session_data["header_action"],
page_obj=session_page_obj,
elided_page_range=session_elided_page_range,
rows = [
[
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
ButtonGroup(
[
{
"href": reverse(
"games:list_sessions_end_session", args=[session.pk]
),
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_session", args=[session.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for session in page_obj.object_list
]
table = SimpleTable(
columns=["Game", "Date", "Duration", "Actions"],
rows=rows,
header_action=header_action,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
playevents_table = SimpleTable(
columns=playevent_data["columns"], rows=playevent_data["rows"]
return _game_section("Sessions", session_count, table, "No sessions yet.")
def _playevents_section(game: Game) -> SafeText:
playevents = game.playevents.all()
data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
table = SimpleTable(columns=data["columns"], rows=data["rows"])
return _game_section(
"Play Events", playevents.count(), table, "No play events yet."
)
history = Div(
def _history_section(game: Game) -> SafeText:
statuschanges = game.status_changes.all()
return Div(
[
("class", "mb-6"),
("id", "history-container"),
@@ -809,36 +798,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
("hx-swap", "outerHTML"),
],
[
H1(children=["History"], badge=statuschange_count),
H1(children=["History"], badge=statuschanges.count()),
_game_history(statuschanges),
],
)
_GET_SESSION_COUNT_SCRIPT = mark_safe(
"<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_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(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
'.textContent.match("[0-9]+");\n'
" }\n"
" </script>"
),
_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,
+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"),
)
+8 -5
View File
@@ -176,7 +176,9 @@ def compute_stats(year: int | None = None) -> StatsData:
unique_days_percent = int(unique_days / 365 * 100)
# ── Spending ─────────────────────────────────────────────────────────────
total_spent = without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0
total_spent = (
without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0
)
without_refunded_count = without_refunded.count()
# ── Purchase breakdown ───────────────────────────────────────────────────
@@ -185,7 +187,10 @@ def compute_stats(year: int | None = None) -> StatsData:
without_refunded.filter(not_finished_q)
.filter(infinite=False)
.filter(only_games_and_dlc)
.filter(~Q(games__status=Game.Status.RETIRED) & ~Q(games__status=Game.Status.ABANDONED))
.filter(
~Q(games__status=Game.Status.RETIRED)
& ~Q(games__status=Game.Status.ABANDONED)
)
)
dropped = (
purchases.filter(not_finished_q)
@@ -270,9 +275,7 @@ def compute_stats(year: int | None = None) -> StatsData:
data: StatsData = {
"year": year_label,
"title": f"{year_label} Stats",
"total_hours": format_duration(
sessions.total_duration_unformatted(), "%2.0H"
),
"total_hours": format_duration(sessions.total_duration_unformatted(), "%2.0H"),
"total_sessions": sessions.count(),
"unique_days": unique_days,
"unique_days_percent": unique_days_percent,
+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()
+12 -4
View File
@@ -47,14 +47,20 @@ class ComputeStatsTest(TestCase):
# Game A in 2023: 1h + 1.5h on the same day = 2.5h
Session.objects.create(
game=self.game_a, timestamp_start=dt(2023, 6, 10, 10), timestamp_end=dt(2023, 6, 10, 11)
game=self.game_a,
timestamp_start=dt(2023, 6, 10, 10),
timestamp_end=dt(2023, 6, 10, 11),
)
Session.objects.create(
game=self.game_a, timestamp_start=dt(2023, 6, 10, 14), timestamp_end=dt(2023, 6, 10, 15, 30)
game=self.game_a,
timestamp_start=dt(2023, 6, 10, 14),
timestamp_end=dt(2023, 6, 10, 15, 30),
)
# Game B in 2023: 1h tracked + 2h manual (no end) = 3h total
Session.objects.create(
game=self.game_b, timestamp_start=dt(2023, 7, 1, 20), timestamp_end=dt(2023, 7, 1, 21)
game=self.game_b,
timestamp_start=dt(2023, 7, 1, 20),
timestamp_end=dt(2023, 7, 1, 21),
)
Session.objects.create(
game=self.game_b,
@@ -63,7 +69,9 @@ class ComputeStatsTest(TestCase):
)
# Game A in 2022 (only counts toward all-time): 2h
Session.objects.create(
game=self.game_a, timestamp_start=dt(2022, 5, 1, 10), timestamp_end=dt(2022, 5, 1, 12)
game=self.game_a,
timestamp_start=dt(2022, 5, 1, 10),
timestamp_end=dt(2022, 5, 1, 12),
)
# ── shared metrics (characterization) ──
+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,
)