diff --git a/common/COMPONENT_IMPROVEMENTS.md b/common/COMPONENT_IMPROVEMENTS.md
deleted file mode 100644
index b3037f6..0000000
--- a/common/COMPONENT_IMPROVEMENTS.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Suggested Improvements to common/components.py
-
-## Completed
-
-### Caching on template rendering
-- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
-- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
-- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
-- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
-- Verified working: identical calls return identical output, different inputs produce separate cache entries
-
-### Non-deterministic IDs
-`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
-- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
-- `games/templatetags/randomid.py` uses the same hash-based approach
-- Fixes: caching (Popover output now cacheable), page consistency, thread safety
-
-### Inconsistent return types
-All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
-
-### Fragile A() URL resolution
-Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
-
-### Toast XSS vulnerability
-The vulnerable `Toast()` component (which used unsafe string escaping for
-Alpine.js interpolation) had no callers and was deleted entirely. Toast display
-is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
-headers → `show-toast` CustomEvent → Alpine store.
-
-### Default mutable arguments
-All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
-
-What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
-
-### NameWithIcon dead code and untestable design
-The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
-
-**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
-
-### No tests
-Zero test coverage for the entire component system.
-
-**Fix**: Add unit tests for each component function — basic rendering, edge cases,
-and cache hit/miss verification.
-
-**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
diff --git a/common/components.py b/common/components.py
index ec9d0b0..0f98499 100644
--- a/common/components.py
+++ b/common/components.py
@@ -1,14 +1,12 @@
import hashlib
-import json
from functools import lru_cache
from typing import Any
-from django.conf import settings
-from django.template import TemplateDoesNotExist
+from django.middleware.csrf import get_token
from django.template.defaultfilters import floatformat
-from django.template.loader import render_to_string
+from django.templatetags.static import static
from django.urls import reverse
-from django.utils.html import conditional_escape
+from django.utils.html import conditional_escape, escape
from django.utils.safestring import SafeText, mark_safe
from common.icons import get_icon
@@ -34,49 +32,52 @@ _SIZE_CLASSES = {
}
-def _render_cached_impl(template: str, context_json: str) -> str:
- context = json.loads(context_json)
- context["slot"] = mark_safe(context["slot"])
- return render_to_string(template, context)
+@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`.
-
-if not settings.DEBUG:
- _render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
-else:
- _render_cached = _render_cached_impl
-
-
-def enable_cache():
- """Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
- global _render_cached
- _render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
+ Inputs are fully hashable and fully determine the output, so identical
+ elements are rendered once. `attrs_key` is (name, stringified value) pairs
+ (attribute values are always escaped). `children_key` is (child, is_safe)
+ pairs: SafeText children pass through, plain strings are escaped. The
+ `is_safe` flag is part of the key on purpose — otherwise a safe ``""``
+ and an unsafe ``""`` (equal as strings) would collide and one would
+ render with the wrong escaping.
+ """
+ children_blob = "\n".join(
+ child if is_safe else escape(child) for child, is_safe in children_key
+ )
+ if attrs_key:
+ attributes_blob = " " + " ".join(
+ f'{name}="{escape(value)}"' for name, value in attrs_key
+ )
+ else:
+ attributes_blob = ""
+ return f"<{tag_name}{attributes_blob}>{children_blob}{tag_name}>"
def Component(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
- template: str = "",
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 and not template:
- raise ValueError("One of template or tag_name is required.")
+ if not tag_name:
+ raise ValueError("tag_name is required.")
if isinstance(children, str):
children = [children]
- childrenBlob = "\n".join(conditional_escape(child) for child in children)
- if len(attributes) == 0:
- attributesBlob = ""
- else:
- attributesList = [f'{name}="{conditional_escape(str(value))}"' for name, value in attributes]
- attributesBlob = f" {' '.join(attributesList)}"
- tag: str = ""
- if tag_name != "":
- tag = f"<{tag_name}{attributesBlob}>{childrenBlob}{tag_name}>"
- elif template != "":
- context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
- tag = _render_cached(template, json.dumps(context, sort_keys=True))
- return mark_safe(tag)
+ 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:
@@ -84,7 +85,11 @@ def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
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))]
+ base = (
+ content_hash[:length]
+ if not seed
+ else content_hash[: max(0, length - len(seed))]
+ )
return seed + base
@@ -413,18 +418,61 @@ def Input(
)
-def Form(
- action="",
- method="get",
- attributes: list[HTMLAttribute] | None = None,
- children: list[HTMLTag] | HTMLTag | None = None,
+def CsrfInput(request) -> SafeText:
+ """Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
+ return mark_safe(
+ f' '
+ )
+
+
+def ModuleScript(filename: str) -> SafeText:
+ """A `'
+ )
+
+
+def AddForm(
+ form,
+ *,
+ request,
+ fields: SafeText | str | None = None,
+ additional_row: SafeText | str = "",
+ submit_class: str = "mt-3",
) -> SafeText:
- attributes = attributes or []
- children = children or []
- return Component(
+ """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=attributes + [("action", action), ("method", method)],
- children=children,
+ 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],
+ )
+ ],
)
@@ -604,7 +652,8 @@ def H1(
return Component(
tag_name="h1",
attributes=[("class", heading_class)],
- children=(children if isinstance(children, list) else [children]) + ([badge_html] if badge_html else []),
+ children=(children if isinstance(children, list) else [children])
+ + ([badge_html] if badge_html else []),
)
@@ -634,9 +683,7 @@ def Modal(
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
),
],
- children=(
- children if isinstance(children, list) else [children]
- ),
+ children=(children if isinstance(children, list) else [children]),
),
],
)
@@ -738,63 +785,147 @@ def TableHeader(
)
-def Table(columns: list[str] | None = None, children=None) -> SafeText:
- """Standalone table with header and body slot.
+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()
- Currently unused — superseded by simple_table. Kept for optional future use.
- """
+
+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'{conditional_escape(page)} '
+ )
+ else:
+ pages_html += (
+ '{conditional_escape(page)} '
+ )
+
+ if page_obj.has_previous():
+ prev_html = (
+ f'Previous '
+ )
+ else:
+ prev_html = (
+ 'Previous '
+ )
+
+ if page_obj.has_next():
+ next_html = (
+ f'Next '
+ )
+ else:
+ next_html = (
+ 'Next '
+ )
+
+ return (
+ ''
+ ''
+ f'{page_obj.start_index()} —'
+ f'{page_obj.end_index()} of '
+ f'{page_obj.paginator.count} '
+ ''
+ f"{prev_html}{pages_html}{next_html}"
+ " "
+ )
+
+
+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 []
- children = children or []
- return Component(
- tag_name="div",
- attributes=[("class", "relative overflow-x-auto shadow-md sm:rounded-lg")],
- children=[
- Component(
- tag_name="table",
- attributes=[
- (
- "class",
- "w-full text-sm text-left rtl:text-right "
- "text-gray-500 dark:text-gray-400",
- ),
- ],
- children=[
- Component(
- tag_name="thead",
- attributes=[
- (
- "class",
- "text-xs text-gray-700 uppercase bg-gray-50 "
- "dark:bg-gray-700 dark:text-gray-400",
- ),
- ],
- children=[
- Component(
- tag_name="tr",
- children=[
- Component(
- tag_name="th",
- attributes=[
- ("scope", "col"),
- ("class", "px-6 py-3"),
- ],
- children=[col],
- )
- for col in columns
- ],
- ),
- ],
- ),
- Component(
- tag_name="tbody",
- children=(
- children
- if isinstance(children, list)
- else [children]
- ),
- ),
- ],
- ),
+ rows = rows or []
+
+ header_html = ""
+ if header_action:
+ header_html = str(TableHeader(children=[header_action]))
+
+ columns_html = "".join(
+ f'
{conditional_escape(col)} '
+ 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(
+ ''
+ '
'
+ '
'
+ f"{header_html}"
+ ''
+ f"{columns_html} "
+ ''
+ f"{rows_html}
"
+ f"{pagination_html}
"
+ )
+
+
+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,
+ )
],
)
@@ -912,4 +1043,131 @@ def PurchasePrice(purchase) -> SafeText:
)
+def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
+ """Alpine.js dropdown to change a game's status."""
+ options_html = "\n".join(
+ f""
+ f"{GameStatus(status=value, children=[label], display='flex')}"
+ f" "
+ for value, label in game_statuses
+ )
+ list_items = "\n".join(
+ f""
+ f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
+ f" "
+ for value, label in game_statuses
+ )
+ return mark_safe(f"""
+
+ {_dropdown_button_html(options_html, list_items)}
+
+""")
+
+
+def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
+ """Alpine.js dropdown to change a session's device."""
+ device_id = session.device_id or "null"
+ device_name = (session.device.name if session.device else "Unknown").replace(
+ "'", "\\'"
+ )
+
+ list_items = "\n".join(
+ f'{d.name} "
+ for d in session_devices
+ )
+
+ return mark_safe(f"""
+
+ {
+ _dropdown_button_html(
+ ' ' + str(Icon("arrowdown")), list_items
+ )
+ }
+
+""")
+
+
+def _dropdown_button_html(button_content: str, list_items: str) -> str:
+ """Shared dropdown button + list structure for Alpine.js selectors."""
+ return (
+ ''
+ '
'
+ f'{button_content} '
+ '"
+ " "
+ "
"
+ )
diff --git a/common/icons.py b/common/icons.py
index 10575cc..0ee4ff9 100644
--- a/common/icons.py
+++ b/common/icons.py
@@ -2,7 +2,7 @@ import functools
from pathlib import Path
_ICON_DIR = (
- Path(__file__).resolve().parent.parent / "games" / "templates" / "cotton" / "icon"
+ Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
)
diff --git a/common/import_data.py b/common/import_data.py
index 43a3463..11bb1ba 100644
--- a/common/import_data.py
+++ b/common/import_data.py
@@ -20,8 +20,8 @@ def import_data(data: DataList):
# try exact match first
try:
game_id = Game.objects.get(name__iexact=name)
- except:
- pass
+ except (Game.DoesNotExist, Game.MultipleObjectsReturned):
+ game_id = None
matching_names[name] = game_id
print(f"Exact matched {len(matching_names)} games.")
diff --git a/common/layout.py b/common/layout.py
new file mode 100644
index 0000000..e67ed0a
--- /dev/null
+++ b/common/layout.py
@@ -0,0 +1,353 @@
+"""A small fast_app-style layout system.
+
+Instead of Django template inheritance (`{% extends "base.html" %}`), views
+build their page body with Python components and wrap it with `Page()` /
+`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
+it hoists shared `` content (the `_HEADERS` block, analogous to
+`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
+"""
+
+import json
+
+from django.contrib.messages import get_messages
+from django.http import HttpRequest, HttpResponse
+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 django_htmx.jinja import django_htmx_script
+
+from games.templatetags.version import version, version_date
+
+# Static head script that sets the dark/light class before paint (avoids FOUC).
+_THEME_FOUC_SCRIPT = """"""
+
+# The main module script: crown icon mount + theme-toggle wiring.
+# Split around the single dynamic value (game.mastered).
+_MAIN_SCRIPT_A = """"""
+
+# Toast notification region (Alpine.js). Verbatim from the old base.html.
+_TOAST_CONTAINER = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
"""
+
+
+def _main_script(mastered: bool) -> str:
+ return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
+
+
+def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
+ """Top navigation bar."""
+ logo = static("icons/schedule.png")
+ return mark_safe(f"""
+
+
+
+ Timetracker
+
+
+ Open main menu
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Today· Last 7 days
+ {today_played}· {last_7_played}
+
+
+ Home
+
+
+
+ New
+
+
+
+
+
+
+
+
+ Manage
+
+
+
+
+
+
+
+ Stats
+
+
+ Log out
+
+
+
+
+ """)
+
+
+def Page(
+ content: SafeText | str,
+ *,
+ request: HttpRequest,
+ title: str = "",
+ scripts: SafeText | str = "",
+ mastered: bool = False,
+) -> SafeText:
+ """Assemble a full HTML document around `content` (the fast_app equivalent)."""
+ from games.views.general import global_current_year, model_counts
+
+ counts = model_counts(request)
+ year = global_current_year(request)["global_current_year"]
+ navbar = Navbar(
+ today_played=counts["today_played"],
+ last_7_played=counts["last_7_played"],
+ current_year=year,
+ )
+
+ messages = [
+ {"message": str(m.message), "type": (m.tags or "info")}
+ for m in get_messages(request)
+ ]
+ # Embed as JSON; guard against `` breaking out of the tag.
+ messages_json = json.dumps(messages).replace("", "<\\/")
+
+ head = (
+ '\n\n \n'
+ ' \n'
+ ' \n'
+ ' \n'
+ ' \n'
+ f" Timetracker - {conditional_escape(title)} \n"
+ f' \n'
+ " \n"
+ f' \n'
+ f" {django_htmx_script(nonce=None)}\n"
+ f' \n'
+ ' \n'
+ ' \n'
+ ' \n'
+ f" {_THEME_FOUC_SCRIPT}\n"
+ " \n"
+ )
+
+ body = (
+ ' \n'
+ f' \n'
+ f' \n'
+ ' \n'
+ f" {navbar}\n"
+ f'
{content}
\n'
+ f'
{version()} ({version_date()}) \n'
+ "
\n"
+ f" {scripts}\n"
+ f" {_main_script(mastered)}\n"
+ ' \n'
+ '
\n'
+ f" {_TOAST_CONTAINER}\n"
+ f' \n'
+ " \n\n"
+ )
+
+ return mark_safe(head + body)
+
+
+def render_page(
+ request: HttpRequest,
+ content: SafeText | str,
+ *,
+ title: str = "",
+ scripts: SafeText | str = "",
+ mastered: bool = False,
+ status: int = 200,
+) -> HttpResponse:
+ """`render()`-style shortcut: build a full page and return an HttpResponse."""
+ return HttpResponse(
+ Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
+ status=status,
+ )
diff --git a/common/utils.py b/common/utils.py
index 45db583..9355b60 100644
--- a/common/utils.py
+++ b/common/utils.py
@@ -153,9 +153,9 @@ def redirect_to(default_view: str, *default_args):
next_url = reverse(default_view, args=default_args)
- response = view_func(
- request, *args, **kwargs
- ) # Execute the original view logic
+ # Execute the original view logic for its side effects, then
+ # redirect to `next_url` instead of returning its response.
+ view_func(request, *args, **kwargs)
return redirect(next_url)
return wrapped_view
diff --git a/games/models.py b/games/models.py
index 2899672..7839525 100644
--- a/games/models.py
+++ b/games/models.py
@@ -327,9 +327,6 @@ class Session(models.Model):
def finish_now(self):
self.timestamp_end = timezone.now()
- def start_now():
- self.timestamp_start = timezone.now()
-
def duration_formatted(self) -> str:
result = format_duration(self.duration_total, "%02.1H")
return result
diff --git a/games/static/base.css b/games/static/base.css
index 8f513a7..69c934e 100644
--- a/games/static/base.css
+++ b/games/static/base.css
@@ -895,18 +895,12 @@
max-width: 96rem;
}
}
- .m-4 {
- margin: calc(var(--spacing) * 4);
- }
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
.mx-auto {
margin-inline: auto;
}
- .my-4 {
- margin-block: calc(var(--spacing) * 4);
- }
.my-6 {
margin-block: calc(var(--spacing) * 6);
}
@@ -1574,9 +1568,6 @@
.w-full {
width: 100%;
}
- .max-w-\(--breakpoint-lg\) {
- max-width: var(--breakpoint-lg);
- }
.max-w-\(--breakpoint-xl\) {
max-width: var(--breakpoint-xl);
}
@@ -3778,11 +3769,6 @@
text-underline-offset: 4px;
}
}
- .\[\&_h1\]\:mb-2 {
- & h1 {
- margin-bottom: calc(var(--spacing) * 2);
- }
- }
.\[\&_li\:first-of-type_a\]\:rounded-none {
& li:first-of-type a {
border-radius: 0;
diff --git a/games/tasks.py b/games/tasks.py
index 7fa94d1..bb113b5 100644
--- a/games/tasks.py
+++ b/games/tasks.py
@@ -4,10 +4,10 @@ import requests
from django.db import models
from django.template.defaultfilters import floatformat
-logger = logging.getLogger("games")
-
from games.models import ExchangeRate, Purchase
+logger = logging.getLogger("games")
+
# fixme: save preferred currency in user model
currency_to = "CZK"
currency_to = currency_to.upper()
diff --git a/games/templates/add.html b/games/templates/add.html
deleted file mode 100644
index d1ef603..0000000
--- a/games/templates/add.html
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
diff --git a/games/templates/add_game.html b/games/templates/add_game.html
deleted file mode 100644
index 6d486c3..0000000
--- a/games/templates/add_game.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- Submit & Create Purchase
-
-
-
diff --git a/games/templates/add_purchase.html b/games/templates/add_purchase.html
deleted file mode 100644
index abcfe8f..0000000
--- a/games/templates/add_purchase.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
- Submit & Create Session
-
-
-
-
-
diff --git a/games/templates/add_session.html b/games/templates/add_session.html
deleted file mode 100644
index 966bd23..0000000
--- a/games/templates/add_session.html
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
-
diff --git a/games/templates/cotton/button.html b/games/templates/cotton/button.html
deleted file mode 100644
index 79d8f81..0000000
--- a/games/templates/cotton/button.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
-{% load button_tag %}
-{% python_button color=color size=size icon=icon type=type class_=class hx_get=hx_get hx_target=hx_target hx_swap=hx_swap title=title onclick=onclick data_target=data_target data_type=data_type name=name slot=slot %}
diff --git a/games/templates/cotton/button_group.html b/games/templates/cotton/button_group.html
deleted file mode 100644
index 88bb5d0..0000000
--- a/games/templates/cotton/button_group.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load button_group_tag %}
-{% python_button_group buttons=buttons %}
diff --git a/games/templates/cotton/gamelink.html b/games/templates/cotton/gamelink.html
deleted file mode 100644
index 834651c..0000000
--- a/games/templates/cotton/gamelink.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load gamelink_tag %}
-{% python_gamelink game_id=game_id name=name slot=slot %}
diff --git a/games/templates/cotton/gamestatus.html b/games/templates/cotton/gamestatus.html
deleted file mode 100644
index 2e8b90b..0000000
--- a/games/templates/cotton/gamestatus.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load gamestatus_tag %}
-{% python_gamestatus status=status display=display class_=class slot=slot %}
diff --git a/games/templates/cotton/h1.html b/games/templates/cotton/h1.html
deleted file mode 100644
index 091e1e6..0000000
--- a/games/templates/cotton/h1.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
-{% load h1_tag %}
-{% python_h1 badge=badge slot=slot %}
diff --git a/games/templates/cotton/layouts/add.html b/games/templates/cotton/layouts/add.html
deleted file mode 100644
index 74fc521..0000000
--- a/games/templates/cotton/layouts/add.html
+++ /dev/null
@@ -1,28 +0,0 @@
-
-{% load static %}
-{% if form_content %}
- {{ form_content }}
-{% else %}
-
-{% endif %}
-
-{% if script_name %}
-
-{% endif %}
-
-
diff --git a/games/templates/cotton/layouts/base.html b/games/templates/cotton/layouts/base.html
deleted file mode 100644
index 08f1730..0000000
--- a/games/templates/cotton/layouts/base.html
+++ /dev/null
@@ -1,212 +0,0 @@
-{% load django_htmx %}
-
-
- {% load static %}
-
-
-
-
-
- Timetracker - {{ title }}
-
-
-
- {% django_htmx_script %}
-
-
- {% comment %}
- {% endcomment %}
-
-
-
-
-
-
-
-
- {% include "navbar.html" %}
-
{{ slot }}
- {% load version %}
-
{% version %} ({% version_date %})
-
- {{ scripts }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/games/templates/cotton/popover.html b/games/templates/cotton/popover.html
deleted file mode 100644
index 8636093..0000000
--- a/games/templates/cotton/popover.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
-{% load popover_tag %}
-{% python_popover popover_content=popover_content wrapped_content=wrapped_content wrapped_classes=wrapped_classes id=id slot=slot %}
diff --git a/games/templates/cotton/price_converted.html b/games/templates/cotton/price_converted.html
deleted file mode 100644
index 703a429..0000000
--- a/games/templates/cotton/price_converted.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load price_converted_tag %}
-{% python_price_converted slot=slot %}
diff --git a/games/templates/cotton/table_header.html b/games/templates/cotton/table_header.html
deleted file mode 100644
index 2bc2253..0000000
--- a/games/templates/cotton/table_header.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load table_header_tag %}
-{% python_table_header slot=slot %}
diff --git a/games/templates/cotton/table_row.html b/games/templates/cotton/table_row.html
deleted file mode 100644
index 9ab798d..0000000
--- a/games/templates/cotton/table_row.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load table_row_tag %}
-{% python_table_row data=data %}
diff --git a/games/templates/cotton/table_td.html b/games/templates/cotton/table_td.html
deleted file mode 100644
index 3a0eb64..0000000
--- a/games/templates/cotton/table_td.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load table_td_tag %}
-{% python_table_td slot=slot %}
diff --git a/games/templates/gamestatuschange_confirm_delete.html b/games/templates/gamestatuschange_confirm_delete.html
deleted file mode 100644
index 6a8b759..0000000
--- a/games/templates/gamestatuschange_confirm_delete.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
- {% load static %}
-
-
-
diff --git a/games/templates/cotton/icon/arrowdown.html b/games/templates/icons/arrowdown.html
similarity index 100%
rename from games/templates/cotton/icon/arrowdown.html
rename to games/templates/icons/arrowdown.html
diff --git a/games/templates/cotton/icon/battlenet.html b/games/templates/icons/battlenet.html
similarity index 100%
rename from games/templates/cotton/icon/battlenet.html
rename to games/templates/icons/battlenet.html
diff --git a/games/templates/cotton/icon/bethesda.html b/games/templates/icons/bethesda.html
similarity index 100%
rename from games/templates/cotton/icon/bethesda.html
rename to games/templates/icons/bethesda.html
diff --git a/games/templates/cotton/icon/checkmark.html b/games/templates/icons/checkmark.html
similarity index 100%
rename from games/templates/cotton/icon/checkmark.html
rename to games/templates/icons/checkmark.html
diff --git a/games/templates/cotton/icon/delete.html b/games/templates/icons/delete.html
similarity index 100%
rename from games/templates/cotton/icon/delete.html
rename to games/templates/icons/delete.html
diff --git a/games/templates/cotton/icon/eaorigin.html b/games/templates/icons/eaorigin.html
similarity index 100%
rename from games/templates/cotton/icon/eaorigin.html
rename to games/templates/icons/eaorigin.html
diff --git a/games/templates/cotton/icon/edit.html b/games/templates/icons/edit.html
similarity index 100%
rename from games/templates/cotton/icon/edit.html
rename to games/templates/icons/edit.html
diff --git a/games/templates/cotton/icon/egs.html b/games/templates/icons/egs.html
similarity index 100%
rename from games/templates/cotton/icon/egs.html
rename to games/templates/icons/egs.html
diff --git a/games/templates/cotton/icon/eject.html b/games/templates/icons/eject.html
similarity index 100%
rename from games/templates/cotton/icon/eject.html
rename to games/templates/icons/eject.html
diff --git a/games/templates/cotton/icon/emulated.html b/games/templates/icons/emulated.html
similarity index 100%
rename from games/templates/cotton/icon/emulated.html
rename to games/templates/icons/emulated.html
diff --git a/games/templates/cotton/icon/end.html b/games/templates/icons/end.html
similarity index 100%
rename from games/templates/cotton/icon/end.html
rename to games/templates/icons/end.html
diff --git a/games/templates/cotton/icon/finish.html b/games/templates/icons/finish.html
similarity index 100%
rename from games/templates/cotton/icon/finish.html
rename to games/templates/icons/finish.html
diff --git a/games/templates/cotton/icon/gog.html b/games/templates/icons/gog.html
similarity index 100%
rename from games/templates/cotton/icon/gog.html
rename to games/templates/icons/gog.html
diff --git a/games/templates/cotton/icon/itchio.html b/games/templates/icons/itchio.html
similarity index 100%
rename from games/templates/cotton/icon/itchio.html
rename to games/templates/icons/itchio.html
diff --git a/games/templates/cotton/icon/microsoft.html b/games/templates/icons/microsoft.html
similarity index 100%
rename from games/templates/cotton/icon/microsoft.html
rename to games/templates/icons/microsoft.html
diff --git a/games/templates/cotton/icon/nintendo-3ds.html b/games/templates/icons/nintendo-3ds.html
similarity index 100%
rename from games/templates/cotton/icon/nintendo-3ds.html
rename to games/templates/icons/nintendo-3ds.html
diff --git a/games/templates/cotton/icon/nintendo-switch.html b/games/templates/icons/nintendo-switch.html
similarity index 100%
rename from games/templates/cotton/icon/nintendo-switch.html
rename to games/templates/icons/nintendo-switch.html
diff --git a/games/templates/cotton/icon/nintendo.html b/games/templates/icons/nintendo.html
similarity index 100%
rename from games/templates/cotton/icon/nintendo.html
rename to games/templates/icons/nintendo.html
diff --git a/games/templates/cotton/icon/physical-media.html b/games/templates/icons/physical-media.html
similarity index 100%
rename from games/templates/cotton/icon/physical-media.html
rename to games/templates/icons/physical-media.html
diff --git a/games/templates/cotton/icon/physical.html b/games/templates/icons/physical.html
similarity index 100%
rename from games/templates/cotton/icon/physical.html
rename to games/templates/icons/physical.html
diff --git a/games/templates/cotton/icon/play.html b/games/templates/icons/play.html
similarity index 100%
rename from games/templates/cotton/icon/play.html
rename to games/templates/icons/play.html
diff --git a/games/templates/cotton/icon/playstation.html b/games/templates/icons/playstation.html
similarity index 100%
rename from games/templates/cotton/icon/playstation.html
rename to games/templates/icons/playstation.html
diff --git a/games/templates/cotton/icon/plus.html b/games/templates/icons/plus.html
similarity index 100%
rename from games/templates/cotton/icon/plus.html
rename to games/templates/icons/plus.html
diff --git a/games/templates/cotton/icon/ps1.html b/games/templates/icons/ps1.html
similarity index 100%
rename from games/templates/cotton/icon/ps1.html
rename to games/templates/icons/ps1.html
diff --git a/games/templates/cotton/icon/ps3.html b/games/templates/icons/ps3.html
similarity index 100%
rename from games/templates/cotton/icon/ps3.html
rename to games/templates/icons/ps3.html
diff --git a/games/templates/cotton/icon/ps4.html b/games/templates/icons/ps4.html
similarity index 100%
rename from games/templates/cotton/icon/ps4.html
rename to games/templates/icons/ps4.html
diff --git a/games/templates/cotton/icon/ps5.html b/games/templates/icons/ps5.html
similarity index 100%
rename from games/templates/cotton/icon/ps5.html
rename to games/templates/icons/ps5.html
diff --git a/games/templates/cotton/icon/refund.html b/games/templates/icons/refund.html
similarity index 100%
rename from games/templates/cotton/icon/refund.html
rename to games/templates/icons/refund.html
diff --git a/games/templates/cotton/icon/steam.html b/games/templates/icons/steam.html
similarity index 100%
rename from games/templates/cotton/icon/steam.html
rename to games/templates/icons/steam.html
diff --git a/games/templates/cotton/icon/ubisoft.html b/games/templates/icons/ubisoft.html
similarity index 100%
rename from games/templates/cotton/icon/ubisoft.html
rename to games/templates/icons/ubisoft.html
diff --git a/games/templates/cotton/icon/unspecified.html b/games/templates/icons/unspecified.html
similarity index 100%
rename from games/templates/cotton/icon/unspecified.html
rename to games/templates/icons/unspecified.html
diff --git a/games/templates/cotton/icon/xbox-gamepass.html b/games/templates/icons/xbox-gamepass.html
similarity index 100%
rename from games/templates/cotton/icon/xbox-gamepass.html
rename to games/templates/icons/xbox-gamepass.html
diff --git a/games/templates/cotton/icon/yuzu.html b/games/templates/icons/yuzu.html
similarity index 100%
rename from games/templates/cotton/icon/yuzu.html
rename to games/templates/icons/yuzu.html
diff --git a/games/templates/index.html b/games/templates/index.html
deleted file mode 100644
index 0d1d4d6..0000000
--- a/games/templates/index.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends "base.html" %}
-{% block title %}
- {{ title }}
-{% endblock title %}
-{% block content %}
-
- {% if session_count > 0 %}
- You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
- {% elif not game_available or not platform_available %}
- There are no games in the database. Start by clicking "New Game" and "New Platform".
- {% elif not purchase_available %}
- There are no owned games. Click "New Purchase" at the top.
- {% else %}
- You haven't played any games yet. Click "New Session" to add one now.
- {% endif %}
-
-{% endblock content %}
diff --git a/games/templates/list_playevents.html b/games/templates/list_playevents.html
deleted file mode 100644
index 30361a0..0000000
--- a/games/templates/list_playevents.html
+++ /dev/null
@@ -1,6 +0,0 @@
-
-{% load static %}
-
- {% include "simple_table.html" with rows=data.rows columns=data.columns page_obj=page_obj elided_page_range=elided_page_range header_action=data.header_action %}
-
-
diff --git a/games/templates/list_purchases.html b/games/templates/list_purchases.html
deleted file mode 100644
index 30361a0..0000000
--- a/games/templates/list_purchases.html
+++ /dev/null
@@ -1,6 +0,0 @@
-
-{% load static %}
-
- {% include "simple_table.html" with rows=data.rows columns=data.columns page_obj=page_obj elided_page_range=elided_page_range header_action=data.header_action %}
-
-
diff --git a/games/templates/list_sessions.html b/games/templates/list_sessions.html
deleted file mode 100644
index d0be3e6..0000000
--- a/games/templates/list_sessions.html
+++ /dev/null
@@ -1,73 +0,0 @@
-{% extends "base.html" %}
-{% load static %}
-{% block title %}
- {{ title }}
-{% endblock title %}
-{% block content %}
-
- {% if dataset_count >= 1 %}
- {% url 'games:list_sessions_start_session_from_session' last.id as start_session_url %}
-
- {% endif %}
- {% if dataset_count != 0 %}
-
-
-
- Name
- Start
- End
- Duration
-
-
-
- {% for session in dataset %}
- {% partialdef session-row inline=True %}
-
-
-
-
- {{ session.game.name }}
-
-
-
-
- {{ session.timestamp_start | date:"d/m/Y H:i" }}
-
-
- {% if not session.timestamp_end %}
- {% url 'games:list_sessions_end_session' session.id as end_session_url %}
-
- Finish now?
-
- {% elif session.duration_manual %}
- --
- {% else %}
- {{ session.timestamp_end | date:"d/m/Y H:i" }}
- {% endif %}
-
- {{ session.duration_formatted }}
-
- {% endpartialdef %}
- {% endfor %}
-
-
- {% else %}
-
No sessions found.
- {% endif %}
-
-{% endblock content %}
diff --git a/games/templates/navbar.html b/games/templates/navbar.html
deleted file mode 100644
index 8a4c672..0000000
--- a/games/templates/navbar.html
+++ /dev/null
@@ -1,146 +0,0 @@
-{% load static %}
-
-
-
-
- Timetracker
-
-
- Open main menu
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Today· Last 7 days
- {{ today_played }}· {{ last_7_played }}
-
-
- Home
-
-
-
- New
-
-
-
-
-
-
-
-
-
- Manage
-
-
-
-
-
-
-
-
- Stats
-
-
- Log
- out
-
-
-
-
-
diff --git a/games/templates/partials/delete_game_confirmation.html b/games/templates/partials/delete_game_confirmation.html
deleted file mode 100644
index 9735840..0000000
--- a/games/templates/partials/delete_game_confirmation.html
+++ /dev/null
@@ -1,32 +0,0 @@
-{% load modal_tag %}
-{% python_modal "delete-game-confirmation-modal" %}
- Delete Game
-
- Are you sure you want to delete {{ game.name }} ?
-
-
-{% endpython_modal %}
diff --git a/games/templates/partials/gamestatus_selector.html b/games/templates/partials/gamestatus_selector.html
deleted file mode 100644
index 761a403..0000000
--- a/games/templates/partials/gamestatus_selector.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
- {% for status_value, status_label in game_statuses %}
-
- {{ status_label }}
-
- {% endfor %}
-
-
-
-
- {% for status_value, status_label in game_statuses %}
- {{ status_label }}
- {% endfor %}
-
-
-
-
-
\ No newline at end of file
diff --git a/games/templates/partials/history.html b/games/templates/partials/history.html
deleted file mode 100644
index 5a05989..0000000
--- a/games/templates/partials/history.html
+++ /dev/null
@@ -1,6 +0,0 @@
-
- {% for change in statuschanges %}
-
- {% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from {{ change.get_old_status_display }} to {{ change.get_new_status_display }} (Edit , Delete )
- {% endfor %}
-
\ No newline at end of file
diff --git a/games/templates/partials/refund_purchase_confirmation.html b/games/templates/partials/refund_purchase_confirmation.html
deleted file mode 100644
index 2c17a9c..0000000
--- a/games/templates/partials/refund_purchase_confirmation.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% load modal_tag %}
-{% python_modal "refund-confirmation-modal" %}
- Confirm Refund
-
- Are you sure you want to mark this purchase as refunded?
-
-
-{% endpython_modal %}
diff --git a/games/templates/partials/related_purchase_field.html b/games/templates/partials/related_purchase_field.html
deleted file mode 100644
index 01c3603..0000000
--- a/games/templates/partials/related_purchase_field.html
+++ /dev/null
@@ -1 +0,0 @@
-{{ form.related_purchase }}
diff --git a/games/templates/partials/sessiondevice_selector.html b/games/templates/partials/sessiondevice_selector.html
deleted file mode 100644
index c0d3529..0000000
--- a/games/templates/partials/sessiondevice_selector.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
diff --git a/games/templates/registration/login.html b/games/templates/registration/login.html
deleted file mode 100644
index 93cb622..0000000
--- a/games/templates/registration/login.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-{% load static %}
-
-
Please log in to continue
-
-
diff --git a/games/templates/showcase/buttons.html b/games/templates/showcase/buttons.html
deleted file mode 100644
index de1799a..0000000
--- a/games/templates/showcase/buttons.html
+++ /dev/null
@@ -1,155 +0,0 @@
-{% extends "base.html" %}
-{% load static %}
-{% block title %}
- {{ title }}
-{% endblock title %}
-{% block content %}
-
-
-
No size
-
- No attributes
-
-
- No attributes, blue
-
-
- No attributes, red
-
-
- No attributes, green
-
-
- No attributes, gray
-
-
-
-
No size, icons
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Extra Small, icons
-
- Edit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Small, icons
-
- Edit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Base, icons
-
- Edit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Large, icons
-
- Edit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Extra Large, icons
-
- Edit
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Group (sm)
-
-
- No attributes
-
-
- No attributes, blue
-
-
- No attributes, red
-
-
- No attributes, green
-
-
- No attributes, gray
-
-
-
-
-{% endblock content %}
diff --git a/games/templates/simple_table.html b/games/templates/simple_table.html
deleted file mode 100644
index fb5956d..0000000
--- a/games/templates/simple_table.html
+++ /dev/null
@@ -1,53 +0,0 @@
-{% load param_utils table_header_tag table_row_tag %}
-
-
-
- {% if header_action %}
- {% python_table_header slot=header_action %}
- {% endif %}
-
-
- {% for column in columns %}{{ column }} {% endfor %}
-
-
-
- {% for row in rows %}{% python_table_row data=row %}{% endfor %}
-
-
-
- {% if page_obj and elided_page_range %}
-
- {{ page_obj.start_index }} —{{ page_obj.end_index }} of {{ page_obj.paginator.count }}
-
-
- {% if page_obj.has_previous %}
- Previous
- {% else %}
- Previous
- {% endif %}
- {% for page in elided_page_range %}
-
- {% if page != page_obj.number %}
- {{ page }}
- {% else %}
- {{ page }}
- {% endif %}
-
- {% endfor %}
- {% if page_obj.has_next %}
- Next
- {% else %}
- Next
- {% endif %}
-
-
-
- {% endif %}
-
diff --git a/games/templates/stats.html b/games/templates/stats.html
deleted file mode 100644
index db40856..0000000
--- a/games/templates/stats.html
+++ /dev/null
@@ -1,292 +0,0 @@
-
-{% load static %}
-{% load duration_formatter %}
-{% partialdef purchase-name %}
-{% if purchase.type != 'game' %}
-
- {% if purchase.game_name %}{{ purchase.game_name }}{% else %}{{ purchase.name }}{% endif %} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
-
-{% else %}
- {% if purchase.game_name %}
-
- {% else %}
-
- {% endif %}
-{% endif %}
-{% endpartialdef %}
-
-
-
-
-
Playtime
-
-
-
- Hours
- {{ total_hours }}
-
-
- Sessions
- {{ total_sessions }}
-
-
- Days
- {{ unique_days }} ({{ unique_days_percent }}%)
-
- {% if total_games %}
-
- Games
- {{ total_games }}
-
- {% endif %}
-
- Games ({{ year }})
- {{ total_year_games }}
-
- {% if all_finished_this_year_count %}
-
- Finished
- {{ all_finished_this_year_count }}
-
- {% endif %}
-
- Finished ({{ year }})
- {{ this_year_finished_this_year_count }}
-
- {% if longest_session_game.id %}
-
- Longest session
-
- {{ longest_session_time }} ( )
-
-
- {% endif %}
- {% if highest_session_count_game.id %}
-
- Most sessions
-
- {{ highest_session_count }} ( )
-
-
- {% endif %}
- {% if highest_session_average_game.id %}
-
- Highest session average
-
- {{ highest_session_average }} ( )
-
-
- {% endif %}
- {% if first_play_game.id %}
-
- First play
-
- ({{ first_play_date }})
-
-
- {% endif %}
- {% if last_play_game.id %}
-
- Last play
-
- ({{ last_play_date }})
-
-
- {% endif %}
-
-
- {% if month_playtimes %}
-
Playtime per month
-
-
- {% for month in month_playtimes %}
-
- {{ month.month | date:"F" }}
- {{ month.playtime | format_duration }}
-
- {% endfor %}
-
-
- {% endif %}
-
Purchases
-
-
-
- Total
- {{ all_purchased_this_year_count }}
-
-
- Refunded
-
- {{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
-
-
-
- Dropped
- {{ dropped_count }} ({{ dropped_percentage }}%)
-
-
- Unfinished
-
- {{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
-
-
-
- Backlog Decrease
- {{ backlog_decrease_count }}
-
-
- Spendings ({{ total_spent_currency }})
-
- {{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
-
-
-
-
-
Games by playtime
-
-
-
- Name
- Playtime
-
-
-
- {% for game in top_10_games_by_playtime %}
-
-
-
-
- {{ game.total_playtime | format_duration }}
-
- {% endfor %}
-
-
-
Platforms by playtime
-
-
-
- Platform
- Playtime
-
-
-
- {% for item in total_playtime_per_platform %}
-
- {{ item.platform_name }}
- {{ item.playtime | format_duration }}
-
- {% endfor %}
-
-
- {% if all_finished_this_year %}
-
Finished
-
-
-
- Name
- Date
-
-
-
- {% for purchase in all_finished_this_year %}
-
- {% partial purchase-name %}
- {{ purchase.date_finished | date:"d/m/Y" }}
-
- {% endfor %}
-
-
- {% endif %}
- {% if this_year_finished_this_year %}
-
Finished ({{ year }} games)
-
-
-
- Name
- Date
-
-
-
- {% for purchase in this_year_finished_this_year %}
-
- {% partial purchase-name %}
- {{ purchase.date_finished | date:"d/m/Y" }}
-
- {% endfor %}
-
-
- {% endif %}
- {% if purchased_this_year_finished_this_year %}
-
Bought and Finished ({{ year }})
-
-
-
- Name
- Date
-
-
-
- {% for purchase in purchased_this_year_finished_this_year %}
-
- {% partial purchase-name %}
- {{ purchase.date_finished | date:"d/m/Y" }}
-
- {% endfor %}
-
-
- {% endif %}
- {% if purchased_unfinished %}
-
Unfinished Purchases
-
-
-
- Name
- Price ({{ total_spent_currency }})
- Date
-
-
-
- {% for purchase in purchased_unfinished %}
-
- {% partial purchase-name %}
- {{ purchase.converted_price | floatformat }}
- {{ purchase.date_purchased | date:"d/m/Y" }}
-
- {% endfor %}
-
-
- {% endif %}
- {% if all_purchased_this_year %}
-
All Purchases
-
-
-
- Name
- Price ({{ total_spent_currency }})
- Date
-
-
-
- {% for purchase in all_purchased_this_year %}
-
- {% partial purchase-name %}
- {{ purchase.converted_price | floatformat }}
- {{ purchase.date_purchased | date:"d/m/Y" }}
-
- {% endfor %}
-
-
- {% endif %}
-
-
diff --git a/games/templates/view_game.html b/games/templates/view_game.html
deleted file mode 100644
index 4dad538..0000000
--- a/games/templates/view_game.html
+++ /dev/null
@@ -1,173 +0,0 @@
-
-
-
-
-
- {{ game.name }} {% if game.year_released %} {{ game.year_released }} {% endif %}
-
-
-
-
-
-
-
- {{ game.playtime_formatted }}
-
-
-
-
-
- {{ session_count }}
-
-
-
-
-
- {{ session_average_without_manual }}
-
-
-
-
-
- {{ playrange }}
-
-
-
-
- Original year
- {{ game.original_year_released }}
-
-
- Status
- {% include "partials/gamestatus_selector.html" %}
- {% if game.mastered %}👑{% endif %}
-
-
-
-
-
-
-
- Platform
- {{ game.platform }}
-
-
-
-
-
- Purchases
- {% if purchase_count %}
- {% include "simple_table.html" with rows=purchase_data.rows columns=purchase_data.columns page_obj=None elided_page_range=None header_action=None %}
- {% else %}
- No purchases yet.
- {% endif %}
-
-
- Sessions
- {% if session_count %}
- {% include "simple_table.html" with rows=session_data.rows columns=session_data.columns header_action=session_data.header_action page_obj=session_page_obj elided_page_range=session_elided_page_range %}
- {% else %}
- No sessions yet.
- {% endif %}
-
-
-
- Play Events
- {% if playevent_count %}
- {% include "simple_table.html" with rows=playevent_data.rows columns=playevent_data.columns page_obj=None elided_page_range=None header_action=None %}
- {% else %}
- No play events yet.
- {% endif %}
-
-
- History
- {% include "partials/history.html" %}
-
-
-
-
diff --git a/games/templates/view_purchase.html b/games/templates/view_purchase.html
deleted file mode 100644
index 3d36e02..0000000
--- a/games/templates/view_purchase.html
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
-
- {% if not purchase.name %}
- Unnamed purchase
- {% else %}
- {{ purchase.name }}
- {% endif %}
-
-
-
- {{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
-
-
-
-
-
- Price:
- {{ purchase.standardized_price }}
- ({{ purchase.price | floatformat:2 }} {{ purchase.price_currency }})
-
-
Price per game: {{ purchase.price_per_game | floatformat:0 }} {{ purchase.converted_currency }}
-
-
-
Items:
-
- {% for game in purchase.games.all %}
-
- {% endfor %}
-
-
-
-
-
-
diff --git a/games/templatetags/button_group_tag.py b/games/templatetags/button_group_tag.py
deleted file mode 100644
index d4df162..0000000
--- a/games/templatetags/button_group_tag.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import ButtonGroup
-
-register = template.Library()
-
-
-@register.simple_tag(takes_context=True)
-def python_button_group(context, buttons=None):
- """Template tag that delegates button group rendering to ButtonGroup().
-
- Supports two modes:
- - buttons list passed: renders button links via ButtonGroup()
- - no buttons (slot only): passes through children (showcase usage)
- """
- if buttons is not None:
- return ButtonGroup(buttons)
- # Slot mode: render children directly (for with direct children)
- slot = context.get("slot", "")
- return mark_safe(slot) if slot else ""
diff --git a/games/templatetags/button_tag.py b/games/templatetags/button_tag.py
deleted file mode 100644
index 7cf4b34..0000000
--- a/games/templatetags/button_tag.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import Button
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_button(
- color: str = "blue",
- size: str = "base",
- icon: str = "",
- type: str = "button",
- class_: str = "",
- hx_get: str = "",
- hx_target: str = "",
- hx_swap: str = "",
- title: str = "",
- onclick: str = "",
- data_target: str = "",
- data_type: str = "",
- name: str = "",
- slot: str = "",
-) -> str:
- """Template tag that delegates to the Python Button() component."""
-
- extra_attrs: list[tuple[str, str]] = []
- if class_:
- extra_attrs.append(("class", class_))
- if data_target:
- extra_attrs.append(("data-target", data_target))
- if data_type:
- extra_attrs.append(("data-type", data_type))
-
- children = [mark_safe(slot)] if slot else []
-
- return Button(
- attributes=extra_attrs or None,
- children=children or None,
- size=size,
- icon=icon if isinstance(icon, bool) else str(icon).lower() == "true",
- color=color,
- type=type,
- hx_get=hx_get,
- hx_target=hx_target,
- hx_swap=hx_swap,
- title=title,
- onclick=onclick,
- name=name,
- )
diff --git a/games/templatetags/duration_formatter.py b/games/templatetags/duration_formatter.py
deleted file mode 100644
index 1ec822a..0000000
--- a/games/templatetags/duration_formatter.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from datetime import timedelta
-
-from django import template
-
-from common.time import durationformat, format_duration
-
-register = template.Library()
-
-
-@register.filter(name="format_duration")
-def filter_format_duration(duration: timedelta, argument: str = durationformat):
- return format_duration(duration, format_string=argument)
diff --git a/games/templatetags/gamelink_tag.py b/games/templatetags/gamelink_tag.py
deleted file mode 100644
index b7f55ae..0000000
--- a/games/templatetags/gamelink_tag.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import GameLink
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_gamelink(game_id: int, name: str = "", slot: str = "") -> str:
- children = [mark_safe(slot)] if slot else []
- return GameLink(game_id=game_id, name=name, children=children)
diff --git a/games/templatetags/gamestatus_tag.py b/games/templatetags/gamestatus_tag.py
deleted file mode 100644
index c7f3078..0000000
--- a/games/templatetags/gamestatus_tag.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import GameStatus
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_gamestatus(status: str = "u", display: str = "", class_: str = "", slot: str = "") -> str:
- children = [mark_safe(slot)] if slot else []
- return GameStatus(children=children, status=status, display=display, class_=class_)
diff --git a/games/templatetags/h1_tag.py b/games/templatetags/h1_tag.py
deleted file mode 100644
index e720163..0000000
--- a/games/templatetags/h1_tag.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import H1
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_h1(badge: str = "", slot: str = "") -> str:
- children = [mark_safe(slot)] if slot else []
- return H1(children=children, badge=badge)
diff --git a/games/templatetags/markdown_extras.py b/games/templatetags/markdown_extras.py
deleted file mode 100644
index e64f70a..0000000
--- a/games/templatetags/markdown_extras.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import markdown
-from django import template
-from django.utils.safestring import mark_safe
-
-register = template.Library()
-
-
-@register.filter(name="markdown")
-def markdown_format(text):
- return mark_safe(markdown.markdown(text))
diff --git a/games/templatetags/modal_tag.py b/games/templatetags/modal_tag.py
deleted file mode 100644
index 5dd1a8a..0000000
--- a/games/templatetags/modal_tag.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import Modal
-
-register = template.Library()
-
-
-class ModalNode(template.Node):
- def __init__(self, modal_id, nodelist):
- self.modal_id = template.Variable(modal_id)
- self.nodelist = nodelist
-
- def render(self, context):
- modal_id = self.modal_id.resolve(context)
- content = self.nodelist.render(context)
- return str(
- Modal(modal_id=modal_id, children=[mark_safe(content)])
- )
-
-
-@register.tag("python_modal")
-def do_modal(parser, token):
- bits = token.split_contents()
- tag_name = bits[0]
- if len(bits) != 2:
- raise template.TemplateSyntaxError(
- f"{tag_name} requires exactly one argument: the modal ID"
- )
- modal_id = bits[1]
- nodelist = parser.parse(("endpython_modal",))
- parser.delete_first_token()
- return ModalNode(modal_id, nodelist)
diff --git a/games/templatetags/param_utils.py b/games/templatetags/param_utils.py
deleted file mode 100644
index e3d232b..0000000
--- a/games/templatetags/param_utils.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from typing import Any
-
-from django import template
-from django.http import QueryDict
-
-register = template.Library()
-
-
-@register.simple_tag(takes_context=True)
-def param_replace(context: dict[Any, Any], **kwargs):
- """
- Return encoded URL parameters that are the same as the current
- request's parameters, only with the specified GET parameters added or changed.
- """
- d: QueryDict = context["request"].GET.copy()
- for k, v in kwargs.items():
- d[k] = v
- return d.urlencode()
diff --git a/games/templatetags/popover_tag.py b/games/templatetags/popover_tag.py
deleted file mode 100644
index 7460636..0000000
--- a/games/templatetags/popover_tag.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from django import template
-
-from common.components import _popover_html
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_popover(
- popover_content: str = "",
- wrapped_content: str = "",
- wrapped_classes: str = "",
- id: str = "",
- slot: str = "",
-) -> str:
- """Template tag that generates popover HTML natively.
-
- Called from the cotton/popover.html shim template.
- Delegates HTML generation to _popover_html().
- """
- return _popover_html(
- id=id,
- popover_content=popover_content,
- wrapped_content=wrapped_content,
- wrapped_classes=wrapped_classes,
- slot=slot,
- )
diff --git a/games/templatetags/price_converted_tag.py b/games/templatetags/price_converted_tag.py
deleted file mode 100644
index afd9116..0000000
--- a/games/templatetags/price_converted_tag.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import PriceConverted
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_price_converted(slot: str = "") -> str:
- return PriceConverted(children=[mark_safe(slot)] if slot else [])
diff --git a/games/templatetags/table_header_tag.py b/games/templatetags/table_header_tag.py
deleted file mode 100644
index 6dffedf..0000000
--- a/games/templatetags/table_header_tag.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import TableHeader
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_table_header(slot: str = "") -> str:
- children = [mark_safe(slot)] if slot else []
- return TableHeader(children=children)
diff --git a/games/templatetags/table_row_tag.py b/games/templatetags/table_row_tag.py
deleted file mode 100644
index 554cda3..0000000
--- a/games/templatetags/table_row_tag.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from django import template
-
-from common.components import TableRow
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_table_row(data=None) -> str:
- return TableRow(data=data)
diff --git a/games/templatetags/table_td_tag.py b/games/templatetags/table_td_tag.py
deleted file mode 100644
index f80f0ff..0000000
--- a/games/templatetags/table_td_tag.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from django import template
-from django.utils.safestring import mark_safe
-
-from common.components import TableTd
-
-register = template.Library()
-
-
-@register.simple_tag
-def python_table_td(slot: str = "") -> str:
- children = [mark_safe(slot)] if slot else []
- return TableTd(children=children)
diff --git a/games/urls.py b/games/urls.py
index f7a3b7c..d81a418 100644
--- a/games/urls.py
+++ b/games/urls.py
@@ -1,8 +1,5 @@
from django.urls import path
-app_name = "games"
-
-from games.api import api
from games.views import (
device,
game,
@@ -14,6 +11,8 @@ from games.views import (
statuschange,
)
+app_name = "games"
+
urlpatterns = [
path("", general.index, name="index"),
path("device/add", device.add_device, name="add_device"),
@@ -115,13 +114,11 @@ urlpatterns = [
path(
"session/add/from-game/",
session.new_session_from_existing_session,
- {"template": "view_game.html#session-info"},
name="view_game_start_session_from_session",
),
path(
"session/add/from-list/",
session.new_session_from_existing_session,
- {"template": "list_sessions.html#session-row"},
name="list_sessions_start_session_from_session",
),
path("session//edit", session.edit_session, name="edit_session"),
@@ -133,35 +130,33 @@ urlpatterns = [
path(
"session/end/from-game/",
session.end_session,
- {"template": "view_game.html#session-info"},
name="view_game_end_session",
),
path(
"session/end/from-list/",
session.end_session,
- {"template": "list_sessions.html#session-row"},
name="list_sessions_end_session",
),
path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"),
path(
"statuschange/add",
- statuschange.AddStatusChangeView.as_view(),
+ statuschange.add_statuschange,
name="add_statuschange",
),
path(
"statuschange/edit/",
- statuschange.EditStatusChangeView.as_view(),
+ statuschange.edit_statuschange,
name="edit_statuschange",
),
path(
"statuschange/delete/",
- statuschange.GameStatusChangeDeleteView.as_view(),
+ statuschange.delete_statuschange,
name="delete_statuschange",
),
path(
"statuschange/list",
- statuschange.GameStatusChangeListView.as_view(),
+ statuschange.list_statuschanges,
name="list_statuschanges",
),
path("stats/", general.stats_alltime, name="stats_alltime"),
diff --git a/games/views/auth.py b/games/views/auth.py
new file mode 100644
index 0000000..1839d81
--- /dev/null
+++ b/games/views/auth.py
@@ -0,0 +1,57 @@
+"""Authentication views rendered with the Python layout (replaces
+registration/login.html)."""
+
+from django.contrib.auth import views as auth_views
+from django.http import HttpResponse
+from django.utils.safestring import SafeText, mark_safe
+
+from common.components import Component, CsrfInput, Div, Input
+from common.layout import render_page
+
+
+def _login_content(form, request) -> SafeText:
+ table = Component(
+ tag_name="table",
+ children=[
+ CsrfInput(request),
+ mark_safe(str(form.as_table())),
+ Component(
+ tag_name="tr",
+ children=[
+ Component(tag_name="td"),
+ Component(
+ tag_name="td",
+ children=[
+ Input(type="submit", attributes=[("value", "Login")])
+ ],
+ ),
+ ],
+ ),
+ ],
+ )
+ return Div(
+ [("class", "flex items-center flex-col")],
+ [
+ Component(
+ tag_name="h2",
+ attributes=[("class", "text-3xl text-white mb-8")],
+ children=["Please log in to continue"],
+ ),
+ Component(
+ tag_name="form",
+ attributes=[("method", "post")],
+ children=[table],
+ ),
+ ],
+ )
+
+
+class LoginView(auth_views.LoginView):
+ """Django's LoginView, but the page body is built in Python."""
+
+ def render_to_response(self, context, **response_kwargs) -> HttpResponse:
+ return render_page(
+ self.request,
+ _login_content(context["form"], self.request),
+ title="Login",
+ )
diff --git a/games/views/device.py b/games/views/device.py
index 79083ae..4e381dc 100644
--- a/games/views/device.py
+++ b/games/views/device.py
@@ -1,12 +1,18 @@
-from typing import Any
-
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
-from common.components import A, Button, ButtonGroup, Icon
+from common.components import (
+ A,
+ AddForm,
+ Button,
+ ButtonGroup,
+ Icon,
+ paginated_table_content,
+)
+from common.layout import render_page
from common.time import dateformat, local_strftime
from games.forms import DeviceForm
from games.models import Device
@@ -14,7 +20,6 @@ from games.models import Device
@login_required
def list_devices(request: HttpRequest) -> HttpResponse:
- context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
devices = Device.objects.order_by("-created_at")
@@ -23,50 +28,50 @@ def list_devices(request: HttpRequest) -> HttpResponse:
paginator = Paginator(devices, limit)
page_obj = paginator.get_page(page_number)
devices = page_obj.object_list
+ elided_page_range = (
+ page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
+ if page_obj
+ else None
+ )
- context = {
- "title": "Manage devices",
- "page_obj": page_obj or None,
- "elided_page_range": (
- page_obj.paginator.get_elided_page_range(
- page_number, on_each_side=1, on_ends=1
- )
- if page_obj
- else None
- ),
- "data": {
- "header_action": A([], Button([], "Add device"), url_name="games:add_device"),
- "columns": [
- "Name",
- "Type",
- "Created",
- "Actions",
- ],
- "rows": [
- [
- device.name,
- device.get_type_display(),
- local_strftime(device.created_at, dateformat),
- ButtonGroup(
- [
- {
- "href": reverse("games:edit_device", args=[device.pk]),
- "slot": Icon("edit"),
- "color": "gray",
- },
- {
- "href": reverse("games:delete_device", args=[device.pk]),
- "slot": Icon("delete"),
- "color": "red",
- },
- ]
- ),
- ]
- for device in devices
- ],
- },
+ data = {
+ "header_action": A([], Button([], "Add device"), url_name="games:add_device"),
+ "columns": [
+ "Name",
+ "Type",
+ "Created",
+ "Actions",
+ ],
+ "rows": [
+ [
+ device.name,
+ device.get_type_display(),
+ local_strftime(device.created_at, dateformat),
+ ButtonGroup(
+ [
+ {
+ "href": reverse("games:edit_device", args=[device.pk]),
+ "slot": Icon("edit"),
+ "color": "gray",
+ },
+ {
+ "href": reverse("games:delete_device", args=[device.pk]),
+ "slot": Icon("delete"),
+ "color": "red",
+ },
+ ]
+ ),
+ ]
+ for device in devices
+ ],
}
- return render(request, "list_purchases.html", context)
+ content = paginated_table_content(
+ data,
+ page_obj=page_obj,
+ elided_page_range=elided_page_range,
+ request=request,
+ )
+ return render_page(request, content, title="Manage devices")
@login_required
@@ -77,8 +82,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
form.save()
return redirect("games:list_devices")
- context: dict[str, Any] = {"form": form, "title": "Edit device"}
- return render(request, "add.html", context)
+ return render_page(request, AddForm(form, request=request), title="Edit device")
@login_required
@@ -90,12 +94,9 @@ def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
@login_required
def add_device(request: HttpRequest) -> HttpResponse:
- context: dict[str, Any] = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("games:index")
- context["form"] = form
- context["title"] = "Add New Device"
- return render(request, "add.html", context)
+ return render_page(request, AddForm(form, request=request), title="Add New Device")
diff --git a/games/views/game.py b/games/views/game.py
index fad055c..31962cf 100644
--- a/games/views/game.py
+++ b/games/views/game.py
@@ -2,25 +2,39 @@ from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
-from django.db.models import Prefetch, Q
+from django.middleware.csrf import get_token
+from django.db.models import Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
-from django.shortcuts import get_object_or_404, redirect, render
-from django.template.loader import render_to_string
+from django.shortcuts import get_object_or_404, redirect
+from django.template.defaultfilters import date as date_filter
from django.urls import reverse
+from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
+ AddForm,
Button,
ButtonGroup,
+ Component,
+ CsrfInput,
Div,
+ GameStatus,
+ GameStatusSelector,
+ H1,
Icon,
SearchField,
LinkedPurchase,
+ Modal,
+ ModuleScript,
NameWithIcon,
Popover,
PopoverTruncated,
PurchasePrice,
+ SimpleTable,
+ paginated_table_content,
)
+from common.icons import get_icon
+from common.layout import render_page
from common.time import (
dateformat,
format_duration,
@@ -29,14 +43,13 @@ from common.time import (
)
from common.utils import build_dynamic_filter, safe_division, truncate
from games.forms import GameForm
-from games.models import Game, Purchase
+from games.models import Game
from games.views.general import use_custom_redirect
from games.views.playevent import create_playevent_tabledata
@login_required
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
- context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
games = Game.objects.order_by("-created_at")
@@ -66,77 +79,70 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
page_obj = paginator.get_page(page_number)
games = page_obj.object_list
- context = {
- "title": "Manage games",
- "page_obj": page_obj or None,
- "elided_page_range": (
- page_obj.paginator.get_elided_page_range(
- page_number, on_each_side=1, on_ends=1
- )
- if page_obj
- else None
+ elided_page_range = (
+ page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
+ if page_obj
+ else None
+ )
+
+ data = {
+ "header_action": Div(
+ children=[
+ SearchField(search_string=search_string),
+ A([], Button([], "Add game"), url_name="games:add_game"),
+ ],
+ attributes=[("class", "flex justify-between")],
),
- "data": {
- "header_action": Div(
- children=[
- SearchField(search_string=search_string),
- A([], Button([], "Add game"), url_name="games:add_game"),
- ],
- attributes=[("class", "flex justify-between")],
- ),
- "columns": [
- "Name",
- "Sort Name",
- "Year",
- "Status",
- "Wikidata",
- "Created",
- "Actions",
- ],
- "rows": [
- [
- NameWithIcon(game=game),
- PopoverTruncated(
- game.sort_name
- if game.sort_name is not None and game.name != game.sort_name
- else "(identical)"
- ),
- game.year_released,
- render_to_string(
- "partials/gamestatus_selector.html",
+ "columns": [
+ "Name",
+ "Sort Name",
+ "Year",
+ "Status",
+ "Wikidata",
+ "Created",
+ "Actions",
+ ],
+ "rows": [
+ [
+ NameWithIcon(game=game),
+ PopoverTruncated(
+ game.sort_name
+ if game.sort_name is not None and game.name != game.sort_name
+ else "(identical)"
+ ),
+ game.year_released,
+ GameStatusSelector(game, Game.Status.choices, get_token(request)),
+ game.wikidata,
+ local_strftime(game.created_at, dateformat),
+ ButtonGroup(
+ [
{
- "game": game,
- "game_statuses": Game.Status.choices,
+ "href": reverse("games:edit_game", args=[game.pk]),
+ "slot": Icon("edit"),
+ "color": "gray",
},
- request=request,
- ),
- game.wikidata,
- local_strftime(game.created_at, dateformat),
- ButtonGroup(
- [
- {
- "href": reverse("games:edit_game", args=[game.pk]),
- "slot": Icon("edit"),
- "color": "gray",
- },
- {
- "href": reverse("games:delete_game", args=[game.pk]),
- "slot": Icon("delete"),
- "color": "red",
- },
- ]
- ),
- ]
- for game in games
- ],
- },
+ {
+ "href": reverse("games:delete_game", args=[game.pk]),
+ "slot": Icon("delete"),
+ "color": "red",
+ },
+ ]
+ ),
+ ]
+ for game in games
+ ],
}
- return render(request, "list_purchases.html", context)
+ content = paginated_table_content(
+ data,
+ page_obj=page_obj,
+ elided_page_range=elided_page_range,
+ request=request,
+ )
+ return render_page(request, content, title="Manage games")
@login_required
def add_game(request: HttpRequest) -> HttpResponse:
- context: dict[str, Any] = {}
form = GameForm(request.POST or None)
if form.is_valid():
game = form.save()
@@ -147,27 +153,154 @@ def add_game(request: HttpRequest) -> HttpResponse:
else:
return redirect("games:list_games")
- context["form"] = form
- context["title"] = "Add New Game"
- context["script_name"] = "add_game.js"
- return render(request, "add_game.html", context)
+ return render_page(
+ request,
+ AddForm(
+ form,
+ request=request,
+ additional_row=Button(
+ [],
+ "Submit & Create Purchase",
+ color="gray",
+ type="submit",
+ name="submit_and_redirect",
+ ),
+ ),
+ title="Add New Game",
+ scripts=ModuleScript("add_game.js"),
+ )
+
+
+def _delete_game_confirmation_modal(
+ game: Game,
+ session_count: int,
+ purchase_count: int,
+ playevent_count: int,
+ request: HttpRequest,
+) -> SafeText:
+ data_items = []
+ if session_count:
+ data_items.append(
+ Component(tag_name="li", children=[f"{session_count} session(s)"])
+ )
+ if purchase_count:
+ data_items.append(
+ Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
+ )
+ if playevent_count:
+ data_items.append(
+ Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
+ )
+ if not (session_count or purchase_count or playevent_count):
+ data_items.append(Component(tag_name="li", children=["No associated data"]))
+
+ form = Component(
+ tag_name="form",
+ attributes=[
+ ("hx-post", reverse("games:delete_game", args=[game.id])),
+ ("hx-replace-url", "true"),
+ ("hx-target", "#main-container"),
+ ("hx-select", "#main-container"),
+ ("hx-swap", "outerHTML"),
+ ],
+ children=[
+ CsrfInput(request),
+ Component(
+ tag_name="p",
+ attributes=[
+ (
+ "class",
+ "dark:text-white text-center mt-3 text-sm text-gray-600 "
+ "dark:text-gray-400",
+ )
+ ],
+ children=[
+ "This will permanently delete this game and all associated data:"
+ ],
+ ),
+ Component(
+ tag_name="ul",
+ attributes=[
+ (
+ "class",
+ "dark:text-white text-center mt-1 text-sm text-gray-600 "
+ "dark:text-gray-400 list-disc list-inside",
+ )
+ ],
+ children=data_items,
+ ),
+ Component(
+ tag_name="p",
+ attributes=[
+ (
+ "class",
+ "dark:text-white text-center mt-3 text-sm font-medium "
+ "text-red-600 dark:text-red-400",
+ )
+ ],
+ children=["This action cannot be undone."],
+ ),
+ Div(
+ [("class", "items-center mt-5")],
+ [
+ Button(
+ [("class", "w-full")],
+ "Delete",
+ color="red",
+ size="lg",
+ type="submit",
+ ),
+ Button(
+ [("class", "mt-0 w-full")],
+ "Cancel",
+ color="gray",
+ size="base",
+ onclick=(
+ "this.closest('#delete-game-confirmation-modal').remove()"
+ ),
+ ),
+ ],
+ ),
+ ],
+ )
+ return Modal(
+ "delete-game-confirmation-modal",
+ children=[
+ Component(
+ tag_name="h1",
+ attributes=[
+ (
+ "class",
+ "text-2xl leading-6 font-medium dark:text-white text-center",
+ )
+ ],
+ children=["Delete Game"],
+ ),
+ Component(
+ tag_name="p",
+ attributes=[("class", "dark:text-white text-center mt-5")],
+ children=[
+ "Are you sure you want to delete ",
+ Component(tag_name="strong", children=[game.name]),
+ "?",
+ ],
+ ),
+ form,
+ ],
+ )
@login_required
def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id)
- session_count = game.sessions.count()
- purchase_count = game.purchases.count()
- playevent_count = game.playevents.count()
- return render(
- request,
- "partials/delete_game_confirmation.html",
- {
- "game": game,
- "session_count": session_count,
- "purchase_count": purchase_count,
- "playevent_count": playevent_count,
- },
+ return HttpResponse(
+ _delete_game_confirmation_modal(
+ game,
+ game.sessions.count(),
+ game.purchases.count(),
+ game.playevents.count(),
+ request,
+ )
)
@@ -181,35 +314,224 @@ def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
@login_required
@use_custom_redirect
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
- context = {}
purchase = get_object_or_404(Game, id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("games:list_sessions")
- context["title"] = "Edit Game"
- context["form"] = form
- return render(request, "add.html", context)
+ return render_page(request, AddForm(form, request=request), title="Edit Game")
+
+
+# --- view_game content builders -------------------------------------------
+
+_STAT_SVGS = {
+ "hours": ' ',
+ "sessions": ' ',
+ "average": ' ',
+ "playrange": ' ',
+}
+
+_PLAYED_ROW_TEMPLATE = """"""
+
+
+def _played_row(game: Game, request: HttpRequest) -> SafeText:
+ """The 'Played N times' control with its Alpine.js dropdown."""
+ replacements = {
+ "@@PLAYED_COUNT@@": str(game.playevents.count()),
+ "@@ADD_PE@@": reverse("games:add_playevent"),
+ "@@ARROWDOWN@@": get_icon("arrowdown"),
+ "@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
+ "@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
+ "@@CSRF@@": get_token(request),
+ "@@GAME_ID@@": str(game.id),
+ }
+ html = _PLAYED_ROW_TEMPLATE
+ for token, value in replacements.items():
+ html = html.replace(token, value)
+ return mark_safe(html)
+
+
+def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
+ return Popover(
+ popover_content=tooltip,
+ wrapped_classes="flex gap-2 items-center",
+ id=popover_id,
+ children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
+ )
+
+
+def _meta_row(
+ label: str, value: SafeText | str, extra: SafeText | str = ""
+) -> SafeText:
+ children: list[SafeText | str] = [
+ Component(
+ tag_name="span", attributes=[("class", "uppercase")], children=[label]
+ ),
+ value,
+ ]
+ if extra:
+ children.append(extra)
+ return Div([("class", "flex gap-2 items-center")], children)
+
+
+def _game_action_buttons(game: Game) -> SafeText:
+ edit_class = (
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
+ "rounded-s-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:text-white dark:hover:text-white dark:hover:bg-gray-700 "
+ "dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
+ )
+ delete_class = (
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
+ "rounded-e-lg hover:bg-red-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-red-700 "
+ "dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
+ )
+ edit_link = Component(
+ tag_name="a",
+ attributes=[("href", reverse("games:edit_game", args=[game.id]))],
+ children=[
+ Component(
+ tag_name="button",
+ attributes=[("type", "button"), ("class", edit_class)],
+ children=["Edit"],
+ )
+ ],
+ )
+ delete_link = Component(
+ tag_name="a",
+ attributes=[
+ ("href", "#"),
+ ("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
+ ("hx-target", "#global-modal-container"),
+ ],
+ children=[
+ Component(
+ tag_name="button",
+ attributes=[("type", "button"), ("class", delete_class)],
+ children=["Delete"],
+ )
+ ],
+ )
+ return Div(
+ [("class", "inline-flex rounded-md shadow-xs mb-3"), ("role", "group")],
+ [edit_link, delete_link],
+ )
+
+
+def _game_history(statuschanges) -> SafeText:
+ items = []
+ for change in statuschanges:
+ if change.timestamp:
+ prefix = f"{date_filter(change.timestamp, 'd/m/Y H:i')}: Changed"
+ else:
+ prefix = "At some point changed"
+ old_status = GameStatus(
+ status=change.old_status or "u",
+ children=[change.get_old_status_display() if change.old_status else "-"],
+ )
+ new_status = GameStatus(
+ status=change.new_status,
+ children=[change.get_new_status_display()],
+ )
+ edit = Component(
+ tag_name="a",
+ attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
+ children=["Edit"],
+ )
+ delete = Component(
+ tag_name="a",
+ attributes=[
+ ("href", reverse("games:delete_statuschange", args=[change.id]))
+ ],
+ children=["Delete"],
+ )
+ items.append(
+ Component(
+ tag_name="li",
+ attributes=[("class", "text-slate-500")],
+ children=[
+ f"{prefix} status from ",
+ old_status,
+ " to ",
+ new_status,
+ " (",
+ edit,
+ ", ",
+ delete,
+ ")",
+ ],
+ )
+ )
+ return Component(
+ tag_name="ul",
+ attributes=[("class", "list-disc list-inside")],
+ children=items,
+ )
+
+
+def _game_section(
+ title: str, count: int, table: SafeText, empty_message: str
+) -> SafeText:
+ return Div(
+ [("class", "mb-6")],
+ [
+ H1(children=[title], badge=count),
+ table if count else empty_message,
+ ],
+ )
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
- nongame_related_purchases_prefetch: Prefetch[Purchase] = Prefetch(
- "related_purchases",
- queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by(
- "date_purchased"
- ),
- to_attr="nongame_related_purchases",
- )
- game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
- "purchases",
- queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
- nongame_related_purchases_prefetch
- ),
- to_attr="game_purchases",
- )
-
purchases = game.purchases.order_by("date_purchased")
sessions = game.sessions
@@ -230,7 +552,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
playrange = "N/A"
latest_session = None
- total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
total_hours_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
@@ -251,7 +572,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"color": "gray",
},
{
- "href": reverse("games:delete_purchase", args=[purchase.pk]),
+ "href": reverse(
+ "games:delete_purchase", args=[purchase.pk]
+ ),
"slot": Icon("delete"),
"color": "red",
},
@@ -349,55 +672,166 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
- statuschange_data = {
- "columns": [
- "Old Status",
- "New Status",
- "Timestamp",
- ],
- "rows": [
- [
- statuschange.get_old_status_display()
- if statuschange.old_status
- else "-",
- statuschange.get_new_status_display(),
- local_strftime(statuschange.timestamp, dateformat),
- ]
- for statuschange in statuschanges
- ],
- }
- context: dict[str, Any] = {
- "statuschange_data": statuschange_data,
- "statuschange_count": statuschange_count,
- "statuschanges": statuschanges,
- "game": game,
- "game_statuses": Game.Status.choices,
- "playrange": playrange,
- "purchase_count": game.purchases.count(),
- "session_average_without_manual": round(
- safe_division(
- total_hours_without_manual, int(session_count_without_manual)
+ purchase_count = game.purchases.count()
+ status_selector_html = GameStatusSelector(
+ game, Game.Status.choices, get_token(request)
+ )
+ session_average_without_manual = round(
+ safe_division(total_hours_without_manual, int(session_count_without_manual)),
+ 1,
+ )
+
+ grey_value_class = "text-black dark:text-slate-300"
+ title_span = Component(
+ tag_name="span",
+ attributes=[("class", "text-balance max-w-120 text-4xl")],
+ children=[
+ Component(
+ tag_name="span",
+ attributes=[("class", "font-bold font-serif")],
+ children=[game.name],
),
- 1,
+ ]
+ + (
+ [
+ mark_safe(" "),
+ Popover(
+ popover_content="Original release year",
+ wrapped_classes="text-slate-500 text-2xl",
+ id="popover-year",
+ children=[str(game.year_released)],
+ ),
+ ]
+ if game.year_released
+ else []
),
- "session_count": session_count,
- "sessions": sessions,
- "title": f"Game Overview - {game.name}",
- "hours_sum": total_hours,
- "purchase_data": purchase_data,
- "playevent_data": playevent_data,
- "playevent_count": playevent_count,
- "session_data": session_data,
- "session_page_obj": session_page_obj,
- "session_elided_page_range": (
- session_page_obj.paginator.get_elided_page_range(
- page_number, on_each_side=1, on_ends=1
- )
- if session_page_obj and session_count > 5
- else None
- ),
- }
+ )
+ title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
+
+ stats_row = Div(
+ [("class", "flex gap-4 dark:text-slate-400 mb-3")],
+ [
+ _stat_popover(
+ "popover-hours",
+ "Total hours played",
+ "hours",
+ game.playtime_formatted(),
+ ),
+ _stat_popover(
+ "popover-sessions", "Number of sessions", "sessions", session_count
+ ),
+ _stat_popover(
+ "popover-average",
+ "Average playtime per session",
+ "average",
+ session_average_without_manual,
+ ),
+ _stat_popover(
+ "popover-playrange",
+ "Earliest and latest dates played",
+ "playrange",
+ playrange,
+ ),
+ ],
+ )
+
+ metadata = Div(
+ [("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
+ [
+ _meta_row(
+ "Original year",
+ Component(
+ tag_name="span",
+ attributes=[("class", grey_value_class)],
+ children=[str(game.original_year_released)],
+ ),
+ ),
+ _meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
+ _played_row(game, request),
+ _meta_row(
+ "Platform",
+ Component(
+ tag_name="span",
+ attributes=[("class", grey_value_class)],
+ children=[str(game.platform)],
+ ),
+ ),
+ ],
+ )
+
+ game_info = Div(
+ [("id", "game-info"), ("class", "mb-10")],
+ [title_row, stats_row, metadata, _game_action_buttons(game)],
+ )
+
+ session_elided_page_range = (
+ session_page_obj.paginator.get_elided_page_range(
+ page_number, on_each_side=1, on_ends=1
+ )
+ if session_page_obj and session_count > 5
+ else None
+ )
+
+ purchases_table = SimpleTable(
+ columns=purchase_data["columns"], rows=purchase_data["rows"]
+ )
+ sessions_table = SimpleTable(
+ columns=session_data["columns"],
+ rows=session_data["rows"],
+ header_action=session_data["header_action"],
+ page_obj=session_page_obj,
+ elided_page_range=session_elided_page_range,
+ request=request,
+ )
+ playevents_table = SimpleTable(
+ columns=playevent_data["columns"], rows=playevent_data["rows"]
+ )
+
+ history = Div(
+ [
+ ("class", "mb-6"),
+ ("id", "history-container"),
+ ("hx-get", ""),
+ ("hx-trigger", "status-changed from:body"),
+ ("hx-select", "#history-container"),
+ ("hx-swap", "outerHTML"),
+ ],
+ [
+ H1(children=["History"], badge=statuschange_count),
+ _game_history(statuschanges),
+ ],
+ )
+
+ content = Div(
+ [("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
+ [
+ game_info,
+ _game_section(
+ "Purchases", purchase_count, purchases_table, "No purchases yet."
+ ),
+ _game_section(
+ "Sessions", session_count, sessions_table, "No sessions yet."
+ ),
+ _game_section(
+ "Play Events", playevent_count, playevents_table, "No play events yet."
+ ),
+ history,
+ mark_safe(
+ ""
+ ),
+ ],
+ )
request.session["return_path"] = request.path
- return render(request, "view_game.html", context)
+ return render_page(
+ request,
+ content,
+ title=f"Game Overview - {game.name}",
+ mastered=game.mastered,
+ )
diff --git a/games/views/general.py b/games/views/general.py
index 470528b..2a1461e 100644
--- a/games/views/general.py
+++ b/games/views/general.py
@@ -2,17 +2,31 @@ from datetime import datetime, timedelta
from typing import Any, Callable
from django.contrib.auth.decorators import login_required
-from django.db.models import Avg, Count, ExpressionWrapper, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, fields
+from django.db.models import (
+ Avg,
+ Count,
+ ExpressionWrapper,
+ F,
+ Max,
+ OuterRef,
+ Prefetch,
+ Q,
+ Subquery,
+ Sum,
+ fields,
+)
from django.db.models.functions import TruncDate, TruncMonth
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
-from django.shortcuts import redirect, render
+from django.shortcuts import redirect
from django.urls import reverse
from django.utils.timezone import now as timezone_now
+from common.layout import render_page
from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division
from games.models import Game, Platform, Purchase, Session
+from games.views.stats_content import stats_content
def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -90,15 +104,12 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("games")
- this_year_purchases_without_refunded = Purchase.objects.filter(
- date_refunded=None
- )
+ this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
this_year_purchases_refunded = Purchase.objects.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
- ~Q(games__status="f")
- & ~Q(games__playevents__ended__isnull=False)
+ ~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
@@ -106,14 +117,12 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
- ~Q(games__status="r")
- & ~Q(games__status="a")
+ ~Q(games__status="r") & ~Q(games__status="a")
)
)
this_year_purchases_dropped = (
this_year_purchases.filter(
- ~Q(games__status="f")
- & ~Q(games__playevents__ended__isnull=False)
+ ~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
)
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False)
@@ -144,27 +153,18 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
"-date_finished"
)
- purchased_this_year_finished_this_year = (
- this_year_purchases_without_refunded.filter(pk__in=_finished_purchases_qs.values("pk"))
- .annotate(
- date_finished=Subquery(
- Purchase.objects.filter(pk=OuterRef("pk"))
- .annotate(max_ended=Max("games__playevents__ended"))
- .values("max_ended")[:1]
- )
- )
- ).order_by("-date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
- games_with_playtime = Game.objects.filter(
- sessions__in=this_year_sessions
- ).distinct().annotate(
- total_playtime=Sum(F("sessions__duration_total"))
- ).filter(total_playtime__gt=timedelta(0))
+ games_with_playtime = (
+ Game.objects.filter(sessions__in=this_year_sessions)
+ .distinct()
+ .annotate(total_playtime=Sum(F("sessions__duration_total")))
+ .filter(total_playtime__gt=timedelta(0))
+ )
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
@@ -190,9 +190,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
.order_by("-playtime")
)
- backlog_decrease_count = (
- purchases_finished_this_year.count()
- )
+ backlog_decrease_count = purchases_finished_this_year.count()
first_play_date = "N/A"
last_play_date = "N/A"
@@ -277,14 +275,16 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
}
request.session["return_path"] = request.path
- return render(request, "stats.html", context)
+ return render_page(request, stats_content(context), title=context["title"])
@login_required
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
selected_year = request.GET.get("year")
if selected_year:
- return HttpResponseRedirect(reverse("games:stats_by_year", args=[selected_year]))
+ return HttpResponseRedirect(
+ reverse("games:stats_by_year", args=[selected_year])
+ )
if year == 0:
return HttpResponseRedirect(reverse("games:stats_alltime"))
this_year_sessions = Session.objects.filter(
@@ -338,8 +338,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
# only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
- ~Q(games__status="f")
- & ~Q(games__playevents__ended__year=year)
+ ~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
@@ -348,15 +347,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
# unfinished = not finished AND not dropped
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
- ~Q(games__status="r")
- & ~Q(games__status="a")
+ ~Q(games__status="r") & ~Q(games__status="a")
)
)
# dropped = abandoned OR retired OR refunded (OR logic for transition)
this_year_purchases_dropped = (
this_year_purchases.filter(
- ~Q(games__status="f")
- & ~Q(games__playevents__ended__year=year)
+ ~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
)
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False)
@@ -375,9 +372,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
* 100
)
- purchases_finished_this_year = Purchase.objects.finished().filter(
- games__playevents__ended__year=year
- ).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
+ purchases_finished_this_year = (
+ Purchase.objects.finished()
+ .filter(games__playevents__ended__year=year)
+ .annotate(
+ game_name=F("games__name"), date_finished=F("games__playevents__ended")
+ )
+ )
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(games__year_released=year).order_by(
"games__playevents__ended"
@@ -472,7 +473,6 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
- "all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
@@ -539,7 +539,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
}
request.session["return_path"] = request.path
- return render(request, "stats.html", context)
+ return render_page(request, stats_content(context), title=context["title"])
@login_required
diff --git a/games/views/platform.py b/games/views/platform.py
index c8eec3f..e9139e2 100644
--- a/games/views/platform.py
+++ b/games/views/platform.py
@@ -1,12 +1,18 @@
-from typing import Any
-
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
-from common.components import A, Button, ButtonGroup, Icon
+from common.components import (
+ A,
+ AddForm,
+ Button,
+ ButtonGroup,
+ Icon,
+ paginated_table_content,
+)
+from common.layout import render_page
from common.time import dateformat, local_strftime
from games.forms import PlatformForm
from games.models import Platform
@@ -15,7 +21,6 @@ from games.views.general import use_custom_redirect
@login_required
def list_platforms(request: HttpRequest) -> HttpResponse:
- context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
platforms = Platform.objects.order_by("name")
@@ -24,52 +29,56 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
paginator = Paginator(platforms, limit)
page_obj = paginator.get_page(page_number)
platforms = page_obj.object_list
+ elided_page_range = (
+ page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
+ if page_obj
+ else None
+ )
- context = {
- "title": "Manage platforms",
- "page_obj": page_obj or None,
- "elided_page_range": (
- page_obj.paginator.get_elided_page_range(
- page_number, on_each_side=1, on_ends=1
- )
- if page_obj
- else None
+ data = {
+ "header_action": A(
+ [], Button([], "Add platform"), url_name="games:add_platform"
),
- "data": {
- "header_action": A([], Button([], "Add platform"), url_name="games:add_platform"),
- "columns": [
- "Name",
- "Icon",
- "Group",
- "Created",
- "Actions",
- ],
- "rows": [
- [
- platform.name,
- Icon(platform.icon),
- platform.group,
- local_strftime(platform.created_at, dateformat),
- ButtonGroup(
- [
- {
- "href": reverse("games:edit_platform", args=[platform.pk]),
- "slot": Icon("edit"),
- "color": "gray",
- },
- {
- "href": reverse("games:delete_platform", args=[platform.pk]),
- "slot": Icon("delete"),
- "color": "red",
- },
- ]
- ),
- ]
- for platform in platforms
- ],
- },
+ "columns": [
+ "Name",
+ "Icon",
+ "Group",
+ "Created",
+ "Actions",
+ ],
+ "rows": [
+ [
+ platform.name,
+ Icon(platform.icon),
+ platform.group,
+ local_strftime(platform.created_at, dateformat),
+ ButtonGroup(
+ [
+ {
+ "href": reverse("games:edit_platform", args=[platform.pk]),
+ "slot": Icon("edit"),
+ "color": "gray",
+ },
+ {
+ "href": reverse(
+ "games:delete_platform", args=[platform.pk]
+ ),
+ "slot": Icon("delete"),
+ "color": "red",
+ },
+ ]
+ ),
+ ]
+ for platform in platforms
+ ],
}
- return render(request, "list_purchases.html", context)
+ content = paginated_table_content(
+ data,
+ page_obj=page_obj,
+ elided_page_range=elided_page_range,
+ request=request,
+ )
+ return render_page(request, content, title="Manage platforms")
@login_required
@@ -82,25 +91,21 @@ def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
@login_required
@use_custom_redirect
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
- context = {}
platform = get_object_or_404(Platform, id=platform_id)
form = PlatformForm(request.POST or None, instance=platform)
if form.is_valid():
form.save()
return redirect("games:list_platforms")
- context["title"] = "Edit Platform"
- context["form"] = form
- return render(request, "add.html", context)
+ return render_page(request, AddForm(form, request=request), title="Edit Platform")
@login_required
def add_platform(request: HttpRequest) -> HttpResponse:
- context: dict[str, Any] = {}
form = PlatformForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("games:index")
- context["form"] = form
- context["title"] = "Add New Platform"
- return render(request, "add.html", context)
+ return render_page(
+ request, AddForm(form, request=request), title="Add New Platform"
+ )
diff --git a/games/views/playevent.py b/games/views/playevent.py
index 73de0cb..35813bb 100644
--- a/games/views/playevent.py
+++ b/games/views/playevent.py
@@ -7,10 +7,18 @@ from django.core.paginator import Paginator
from django.db.models import QuerySet
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
-from django.shortcuts import get_object_or_404, render
+from django.shortcuts import get_object_or_404
from django.urls import reverse
-from common.components import A, Button, ButtonGroup, Icon
+from common.components import (
+ A,
+ AddForm,
+ Button,
+ ButtonGroup,
+ Icon,
+ paginated_table_content,
+)
+from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime
from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session
@@ -74,7 +82,9 @@ def create_playevent_tabledata(
for row in row_list
]
return {
- "header_action": A([], Button([], "Add play event"), url_name="games:add_playevent"),
+ "header_action": A(
+ [], Button([], "Add play event"), url_name="games:add_playevent"
+ ),
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
@@ -123,19 +133,19 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
paginator = Paginator(playevents, limit)
page_obj = paginator.get_page(page_number)
playevents = page_obj.object_list
- context: dict[str, Any] = {
- "title": "Manage play events",
- "page_obj": page_obj or None,
- "elided_page_range": (
- page_obj.paginator.get_elided_page_range(
- page_number, on_each_side=1, on_ends=1
- )
- if page_obj
- else None
- ),
- "data": create_playevent_tabledata(playevents, request=request),
- }
- return render(request, "list_playevents.html", context)
+ elided_page_range = (
+ page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
+ if page_obj
+ else None
+ )
+ data = create_playevent_tabledata(playevents, request=request)
+ content = paginated_table_content(
+ data,
+ page_obj=page_obj,
+ elided_page_range=elided_page_range,
+ request=request,
+ )
+ return render_page(request, content, title="Manage play events")
@login_required
@@ -192,22 +202,21 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
game_id = form.instance.game.id
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
- return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
+ return render_page(
+ request, AddForm(form, request=request), title="Add new playthrough"
+ )
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
- context: dict[str, Any] = {}
playevent = get_object_or_404(PlayEvent, id=playevent_id)
form = PlayEventForm(request.POST or None, instance=playevent)
if form.is_valid():
form.save()
- return HttpResponseRedirect(reverse("games:view_game", args=[playevent.game.id]))
+ return HttpResponseRedirect(
+ reverse("games:view_game", args=[playevent.game.id])
+ )
- context = {
- "form": form,
- "title": "Edit Play Event",
- }
- return render(request, "add.html", context)
+ return render_page(request, AddForm(form, request=request), title="Edit Play Event")
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
diff --git a/games/views/purchase.py b/games/views/purchase.py
index 91be1e2..f41cf33 100644
--- a/games/views/purchase.py
+++ b/games/views/purchase.py
@@ -1,5 +1,3 @@
-from typing import Any
-
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@@ -8,12 +6,34 @@ from django.http import (
HttpResponse,
HttpResponseRedirect,
)
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
-from common.components import A, Button, ButtonGroup, Icon, LinkedPurchase, PurchasePrice, TableRow
+from django.template.defaultfilters import date as date_filter
+from django.template.defaultfilters import floatformat
+from django.utils.safestring import SafeText
+
+from common.components import (
+ A,
+ AddForm,
+ Button,
+ ButtonGroup,
+ Component,
+ CsrfInput,
+ Div,
+ GameLink,
+ Icon,
+ LinkedPurchase,
+ Modal,
+ ModuleScript,
+ PriceConverted,
+ PurchasePrice,
+ TableRow,
+ paginated_table_content,
+)
+from common.layout import render_page
from common.time import dateformat
from games.forms import PurchaseForm
from games.models import Game, Purchase
@@ -75,7 +95,6 @@ def _render_purchase_row(purchase):
@login_required
def list_purchases(request: HttpRequest) -> HttpResponse:
- context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
@@ -84,38 +103,61 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
paginator = Paginator(purchases, limit)
page_obj = paginator.get_page(page_number)
purchases = page_obj.object_list
+ elided_page_range = (
+ page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
+ if page_obj
+ else None
+ )
- context = {
- "title": "Manage purchases",
- "page_obj": page_obj or None,
- "elided_page_range": (
- page_obj.paginator.get_elided_page_range(
- page_number, on_each_side=1, on_ends=1
- )
- if page_obj
- else None
+ data = {
+ "header_action": A(
+ [], Button([], "Add purchase"), url_name="games:add_purchase"
),
- "data": {
- "header_action": A([], Button([], "Add purchase"), url_name="games:add_purchase"),
- "columns": [
- "Name",
- "Type",
- "Price",
- "Infinite",
- "Purchased",
- "Refunded",
- "Created",
- "Actions",
- ],
- "rows": [_render_purchase_row(purchase) for purchase in purchases],
- },
+ "columns": [
+ "Name",
+ "Type",
+ "Price",
+ "Infinite",
+ "Purchased",
+ "Refunded",
+ "Created",
+ "Actions",
+ ],
+ "rows": [_render_purchase_row(purchase) for purchase in purchases],
}
- return render(request, "list_purchases.html", context)
+ content = paginated_table_content(
+ data,
+ page_obj=page_obj,
+ elided_page_range=elided_page_range,
+ request=request,
+ )
+ return render_page(request, content, title="Manage purchases")
+
+
+def _purchase_additional_row() -> SafeText:
+ """The 'Submit & Create Session' row shown below the main Submit button."""
+ return Component(
+ tag_name="tr",
+ children=[
+ Component(tag_name="td"),
+ Component(
+ tag_name="td",
+ children=[
+ Button(
+ [],
+ "Submit & Create Session",
+ color="gray",
+ type="submit",
+ name="submit_and_redirect",
+ )
+ ],
+ ),
+ ],
+ )
@login_required
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
- context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
@@ -144,26 +186,28 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
else:
form = PurchaseForm(initial=initial)
- context["form"] = form
- context["title"] = "Add New Purchase"
- context["script_name"] = "add_purchase.js"
- return render(request, "add_purchase.html", context)
+ return render_page(
+ request,
+ AddForm(form, request=request, additional_row=_purchase_additional_row()),
+ title="Add New Purchase",
+ scripts=ModuleScript("add_purchase.js"),
+ )
@login_required
@use_custom_redirect
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
- context = {}
purchase = get_object_or_404(Purchase, id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("games:list_sessions")
- context["title"] = "Edit Purchase"
- context["form"] = form
- context["purchase_id"] = str(purchase_id)
- context["script_name"] = "add_purchase.js"
- return render(request, "add_purchase.html", context)
+ return render_page(
+ request,
+ AddForm(form, request=request, additional_row=_purchase_additional_row()),
+ title="Edit Purchase",
+ scripts=ModuleScript("add_purchase.js"),
+ )
@login_required
@@ -173,13 +217,67 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("games:list_purchases")
+def _view_purchase_content(purchase: Purchase) -> SafeText:
+ first_game = purchase.first_game
+ owned = f"Owned on {date_filter(purchase.date_purchased, 'd/m/Y')}"
+ if purchase.date_refunded:
+ owned += f" (refunded {date_filter(purchase.date_refunded, 'd/m/Y')})"
+
+ row_class = "text-slate-500 text-xl"
+ inner = Div(
+ [("class", "flex flex-col gap-5 mb-3")],
+ [
+ Div(
+ [("class", "font-bold font-serif text-slate-500 text-2xl")],
+ [
+ A(
+ [],
+ first_game.name,
+ href=reverse("games:view_game", args=[first_game.id]),
+ )
+ ],
+ ),
+ Div([("class", row_class)], [purchase.get_type_display()]),
+ Div([("class", row_class)], [owned]),
+ Div(
+ [("class", row_class)], [PriceConverted([purchase.standardized_price])]
+ ),
+ Div(
+ [("class", row_class)],
+ [
+ Component(
+ tag_name="p",
+ children=[
+ "Price per game: ",
+ PriceConverted([floatformat(purchase.price_per_game, 0)]),
+ f" {purchase.converted_currency}",
+ ],
+ )
+ ],
+ ),
+ Div([("class", row_class)], ["Games included in this purchase:"]),
+ Component(
+ tag_name="ul",
+ children=[
+ Component(tag_name="li", children=[GameLink(game.id, game.name)])
+ for game in purchase.games.all()
+ ],
+ ),
+ ],
+ )
+ return Div(
+ [("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
+ [inner],
+ )
+
+
@login_required
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
- return render(
+ return render_page(
request,
- "view_purchase.html",
- {"purchase": purchase, "title": f"Purchase: {purchase.full_name}"},
+ _view_purchase_content(purchase),
+ title=f"Purchase: {purchase.full_name}",
)
@@ -192,15 +290,70 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("games:list_purchases")
+def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
+ form = Component(
+ tag_name="form",
+ attributes=[
+ ("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
+ ("hx-target", f"#purchase-row-{purchase_id}"),
+ ("hx-swap", "outerHTML"),
+ ],
+ children=[
+ CsrfInput(request),
+ Component(
+ tag_name="p",
+ attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
+ children=["Games will be marked as abandoned."],
+ ),
+ Div(
+ [("class", "items-center mt-5")],
+ [
+ Button(
+ [("class", "w-full")],
+ "Refund",
+ color="blue",
+ size="lg",
+ type="submit",
+ ),
+ Button(
+ [("class", "mt-0 w-full")],
+ "Cancel",
+ color="gray",
+ size="base",
+ onclick="this.closest('#refund-confirmation-modal').remove()",
+ ),
+ ],
+ ),
+ ],
+ )
+ return Modal(
+ "refund-confirmation-modal",
+ children=[
+ Component(
+ tag_name="h1",
+ attributes=[
+ (
+ "class",
+ "text-2xl leading-6 font-medium dark:text-white text-center",
+ )
+ ],
+ children=["Confirm Refund"],
+ ),
+ Component(
+ tag_name="p",
+ attributes=[("class", "dark:text-white text-center mt-5")],
+ children=["Are you sure you want to mark this purchase as refunded?"],
+ ),
+ form,
+ ],
+ )
+
+
@login_required
def refund_purchase_confirmation(
request: HttpRequest, purchase_id: int
) -> HttpResponse:
- return render(
- request,
- "partials/refund_purchase_confirmation.html",
- {"purchase_id": purchase_id},
- )
+ return HttpResponse(_refund_confirmation_modal(purchase_id, request))
@login_required
@@ -233,9 +386,7 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
- games: list[str] = []
- games = request.GET.getlist("games")
- context = {}
+ games: list[str] = request.GET.getlist("games")
if games:
form = PurchaseForm()
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
@@ -246,8 +397,7 @@ def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
first_option = qs.first()
if first_option:
form.fields["related_purchase"].initial = first_option.id
- context["form"] = form
- return render(request, "partials/related_purchase_field.html", context)
+ return HttpResponse(str(form["related_purchase"]))
else:
# abort swap
return HttpResponse(status=204)
diff --git a/games/views/session.py b/games/views/session.py
index 2e63602..8296434 100644
--- a/games/views/session.py
+++ b/games/views/session.py
@@ -4,21 +4,29 @@ from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpRequest, HttpResponse
-from django.shortcuts import get_object_or_404, redirect, render
-from django.template.loader import render_to_string
+from django.middleware.csrf import get_token
+from django.shortcuts import get_object_or_404, redirect
+from django.template.defaultfilters import date as date_filter
from django.urls import reverse
from django.utils import timezone
+from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
+ AddForm,
Button,
ButtonGroup,
+ Component,
Div,
Icon,
- SearchField,
+ ModuleScript,
NameWithIcon,
Popover,
+ SearchField,
+ SessionDeviceSelector,
+ paginated_table_content,
)
+from common.layout import render_page
from common.time import (
dateformat,
local_strftime,
@@ -31,7 +39,6 @@ from games.models import Device, Game, Session
@login_required
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
- context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start", "created_at")
@@ -55,120 +62,115 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
page_obj = paginator.get_page(page_number)
sessions = page_obj.object_list
- context = {
- "title": "Manage sessions",
- "page_obj": page_obj or None,
- "elided_page_range": (
- page_obj.paginator.get_elided_page_range(
- page_number, on_each_side=1, on_ends=1
- )
- if page_obj
- else None
- ),
- "data": {
- "header_action": Div(
- children=[
- SearchField(search_string=search_string),
- Div(
- children=[
- A(
- url_name="games:add_session",
- children=Button(
- icon=True,
- size="xs",
- children=[Icon("play"), "LOG"],
- ),
+ elided_page_range = (
+ page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
+ if page_obj
+ else None
+ )
+
+ data = {
+ "header_action": Div(
+ children=[
+ SearchField(search_string=search_string),
+ 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],
+ ),
+ 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 "",
+ ]
+ ),
+ ],
+ attributes=[("class", "flex justify-between")],
+ ),
+ "columns": [
+ "Name",
+ "Date",
+ "Duration",
+ "Device",
+ "Created",
+ "Actions",
+ ],
+ "rows": [
+ {
+ "row_id": f"session-row-{session.pk}",
+ "hx_trigger": "device-changed from:body",
+ "hx_get": "",
+ "hx_select": f"#session-row-{session.pk}",
+ "hx_swap": "outerHTML",
+ "cell_data": [
+ 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(),
+ SessionDeviceSelector(session, device_list, get_token(request)),
+ session.created_at.strftime(dateformat),
+ ButtonGroup(
+ [
+ {
+ "href": reverse(
+ "games:list_sessions_end_session", args=[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}"),
- ],
- )
- ],
+ "slot": Icon("end"),
+ "title": "Finish session now",
+ "color": "green",
+ }
+ if session.timestamp_end is None
+ else {},
+ {
+ "href": reverse(
+ "games:edit_session", args=[session.pk]
),
- )
- if last_session
- else "",
+ "slot": Icon("edit"),
+ "title": "Edit",
+ },
+ {
+ "href": reverse(
+ "games:delete_session", args=[session.pk]
+ ),
+ "slot": Icon("delete"),
+ "title": "Delete",
+ "color": "red",
+ },
]
),
],
- attributes=[("class", "flex justify-between")],
- ),
- "columns": [
- "Name",
- "Date",
- "Duration",
- "Device",
- "Created",
- "Actions",
- ],
- "rows": [
- {
- "row_id": f"session-row-{session.pk}",
- "hx_trigger": "device-changed from:body",
- "hx_get": "",
- "hx_select": f"#session-row-{session.pk}",
- "hx_swap": "outerHTML",
- "cell_data": [
- 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(),
- render_to_string(
- "partials/sessiondevice_selector.html",
- {
- "session": session,
- "session_device": session.device,
- "session_devices": device_list,
- },
- request=request,
- ),
- session.created_at.strftime(dateformat),
- 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"),
- "title": "Edit",
- },
- {
- "href": reverse(
- "games:delete_session", args=[session.pk]
- ),
- "slot": Icon("delete"),
- "title": "Delete",
- "color": "red",
- },
- ]
- ),
- ],
- }
- for session in sessions
- ],
- },
+ }
+ for session in sessions
+ ],
}
- return render(request, "list_purchases.html", context)
+ content = paginated_table_content(
+ data,
+ page_obj=page_obj,
+ elided_page_range=elided_page_range,
+ request=request,
+ )
+ return render_page(request, content, title="Manage sessions")
@login_required
@@ -176,13 +178,60 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
+def _session_fields(form) -> SafeText:
+ """Manual per-field layout for the session form.
+
+ Mirrors the old add_session.html: each field gets its label and widget,
+ and the timestamp fields gain a row of now/toggle/copy helper buttons.
+ """
+ rows: list[SafeText] = []
+ for field in form:
+ children: list[SafeText | str] = [
+ mark_safe(str(field.label_tag())),
+ mark_safe(str(field)),
+ ]
+ if field.name in ("timestamp_start", "timestamp_end"):
+ this_side = "start" if field.name == "timestamp_start" else "end"
+ other_side = "end" if field.name == "timestamp_start" else "start"
+ children.append(
+ Component(
+ tag_name="span",
+ attributes=[
+ (
+ "class",
+ "form-row-button-group flex-row gap-3 justify-start mt-3",
+ ),
+ ("hx-boost", "false"),
+ ],
+ children=[
+ Button(
+ [("data-target", field.name), ("data-type", "now")],
+ "Set to now",
+ size="xs",
+ ),
+ Button(
+ [("data-target", field.name), ("data-type", "toggle")],
+ "Toggle text",
+ size="xs",
+ ),
+ Button(
+ [("data-target", field.name), ("data-type", "copy")],
+ f"Copy {this_side} value to {other_side}",
+ size="xs",
+ ),
+ ],
+ )
+ )
+ rows.append(Div(children=children))
+ return mark_safe("\n".join(rows))
+
+
@login_required
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
- context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last()
- if last != None:
+ if last is not None:
initial["game"] = last.game
if request.method == "POST":
@@ -202,25 +251,116 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
else:
form = SessionForm(initial=initial)
- context["title"] = "Add New Session"
# TODO: re-add custom buttons #91
- context["script_name"] = "add_session.js"
- context["form"] = form
- return render(request, "add_session.html", context)
+ return render_page(
+ request,
+ AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
+ title="Add New Session",
+ scripts=ModuleScript("add_session.js"),
+ )
@login_required
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
- context = {}
session = get_object_or_404(Session, id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
return redirect("games:list_sessions")
- context["title"] = "Edit Session"
- context["script_name"] = "add_session.js"
- context["form"] = form
- return render(request, "add_session.html", context)
+ return render_page(
+ request,
+ AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
+ title="Edit Session",
+ scripts=ModuleScript("add_session.js"),
+ )
+
+
+def _session_row_fragment(session: Session) -> SafeText:
+ """A single session (the old list_sessions.html#session-row partial),
+ returned by the inline end/clone-session HTMX endpoints."""
+ name_link = Component(
+ tag_name="a",
+ attributes=[
+ (
+ "class",
+ "underline decoration-slate-500 sm:decoration-2 inline-block "
+ "truncate max-w-20char group-hover:absolute group-hover:max-w-none "
+ "group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 "
+ "group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 "
+ "group-hover:rounded-xs group-hover:outline-dashed "
+ "group-hover:outline-purple-400 group-hover:outline-4 "
+ "group-hover:decoration-purple-900 group-hover:text-purple-100",
+ ),
+ ("href", reverse("games:view_game", args=[session.game.id])),
+ ],
+ children=[session.game.name],
+ )
+ name_td = Component(
+ tag_name="td",
+ attributes=[
+ (
+ "class",
+ "px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top "
+ "w-24 h-12 group",
+ )
+ ],
+ children=[
+ Component(
+ tag_name="span",
+ attributes=[("class", "inline-block relative")],
+ children=[name_link],
+ )
+ ],
+ )
+ start_td = Component(
+ tag_name="td",
+ attributes=[
+ ("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
+ ],
+ children=[date_filter(session.timestamp_start, "d/m/Y H:i")],
+ )
+
+ if not session.timestamp_end:
+ end_url = reverse("games:list_sessions_end_session", args=[session.id])
+ end_inner: SafeText | str = Component(
+ tag_name="a",
+ attributes=[
+ ("href", end_url),
+ ("hx-get", end_url),
+ ("hx-target", "closest tr"),
+ ("hx-swap", "outerHTML"),
+ ("hx-indicator", "#indicator"),
+ (
+ "onClick",
+ "document.querySelector('#last-session-start')"
+ ".classList.remove('invisible')",
+ ),
+ ],
+ children=[
+ Component(
+ tag_name="span",
+ attributes=[("class", "text-yellow-300")],
+ children=["Finish now?"],
+ )
+ ],
+ )
+ elif session.duration_manual:
+ end_inner = "--"
+ else:
+ end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
+ end_td = Component(
+ tag_name="td",
+ attributes=[
+ ("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
+ ],
+ children=[end_inner],
+ )
+ duration_td = Component(
+ tag_name="td",
+ attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
+ children=[session.duration_formatted()],
+ )
+ return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
def clone_session_by_id(session_id: int) -> Session:
@@ -236,38 +376,21 @@ def clone_session_by_id(session_id: int) -> Session:
@login_required
def new_session_from_existing_session(
- request: HttpRequest, session_id: int, template: str = ""
+ request: HttpRequest, session_id: int
) -> HttpResponse:
session = clone_session_by_id(session_id)
if request.htmx:
- context = {
- "session": session,
- "session_count": int(request.GET.get("session_count", 0)) + 1,
- }
- return render(request, template, context)
+ return HttpResponse(_session_row_fragment(session))
return redirect("games:list_sessions")
@login_required
-def end_session(
- request: HttpRequest, session_id: int, template: str = ""
-) -> HttpResponse:
+def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
session.timestamp_end = timezone.now()
session.save()
if request.htmx:
- context = {
- "session": session,
- "session_count": request.GET.get("session_count", 0),
- }
- return render(request, template, context)
- return redirect("games:list_sessions")
-
-
-@login_required
-def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
- session = get_object_or_404(Session, id=session_id)
- session.delete()
+ return HttpResponse(_session_row_fragment(session))
return redirect("games:list_sessions")
diff --git a/games/views/stats_content.py b/games/views/stats_content.py
new file mode 100644
index 0000000..6b8729b
--- /dev/null
+++ b/games/views/stats_content.py
@@ -0,0 +1,325 @@
+"""Python builder for the stats page body (replaces stats.html).
+
+Both stats views (`stats_alltime`-style and per-year) assemble a `context`
+dict and pass it here. Optional sections are driven by `ctx.get(...)` exactly
+like the old `{% if key %}` blocks: a missing or empty value hides the section.
+"""
+
+from django.template.defaultfilters import date as date_filter
+from django.template.defaultfilters import floatformat
+from django.utils.html import conditional_escape
+from django.utils.safestring import SafeText, mark_safe
+
+from common.components import Component, Div, GameLink
+from common.time import durationformat, format_duration
+
+_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
+_CELL_MONO = f"{_CELL} font-mono"
+_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
+
+
+def _td(children, cls: str = _CELL_MONO) -> SafeText:
+ if not isinstance(children, list):
+ children = [children]
+ children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
+ return Component(tag_name="td", attributes=[("class", cls)], children=children)
+
+
+def _th(text: str, cls: str = _CELL) -> SafeText:
+ return Component(tag_name="th", attributes=[("class", cls)], children=[text])
+
+
+def _tr(cells: list) -> SafeText:
+ return Component(tag_name="tr", children=cells)
+
+
+def _kv(label, value) -> SafeText:
+ """A label/value row: plain label cell + mono value cell."""
+ return _tr([_td(label, _CELL), _td(value)])
+
+
+def _h1(title: str) -> SafeText:
+ return Component(
+ tag_name="h1",
+ attributes=[("class", "text-5xl text-center my-6")],
+ children=[title],
+ )
+
+
+def _table(rows: list, thead: SafeText | None = None) -> SafeText:
+ children = []
+ if thead is not None:
+ children.append(thead)
+ children.append(Component(tag_name="tbody", children=rows))
+ return Component(
+ tag_name="table",
+ attributes=[("class", "responsive-table")],
+ children=children,
+ )
+
+
+def _dur(value) -> str:
+ return format_duration(value, durationformat)
+
+
+def _purchase_name(purchase) -> SafeText:
+ """Mirror of the `purchase-name` partial in the old template."""
+ game_name = getattr(purchase, "game_name", None)
+ first_game = purchase.first_game
+ if purchase.type != "game":
+ name = game_name or purchase.name
+ link = GameLink(first_game.id, name)
+ suffix = f" ({first_game.name} {purchase.get_type_display()})"
+ return mark_safe(str(link) + conditional_escape(suffix))
+ name = game_name or first_game.name
+ return GameLink(first_game.id, name)
+
+
+def _year_dropdown(year, year_range) -> SafeText:
+ options = []
+ for year_item in year_range or []:
+ attrs = [("value", str(year_item))]
+ if year == year_item:
+ attrs.append(("selected", True))
+ options.append(
+ Component(tag_name="option", attributes=attrs, children=[str(year_item)])
+ )
+ select = Component(
+ tag_name="select",
+ attributes=[
+ ("name", "year"),
+ ("id", "yearSelect"),
+ ("onchange", "this.form.submit();"),
+ ("class", "mx-2"),
+ ],
+ children=options,
+ )
+ label = Component(
+ tag_name="label",
+ attributes=[
+ ("class", "text-5xl text-center inline-block mb-10"),
+ ("for", "yearSelect"),
+ ],
+ children=["Stats for:"],
+ )
+ form = Component(
+ tag_name="form",
+ attributes=[("method", "get"), ("class", "text-center")],
+ children=[label, select],
+ )
+ return Div([("class", "flex justify-center items-center")], [form])
+
+
+def _playtime_table(ctx) -> SafeText:
+ year = ctx.get("year")
+ rows = [
+ _kv("Hours", ctx.get("total_hours")),
+ _kv("Sessions", ctx.get("total_sessions")),
+ _kv(
+ "Days",
+ f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)",
+ ),
+ ]
+ if ctx.get("total_games"):
+ rows.append(_kv("Games", ctx.get("total_games")))
+ rows.append(_kv(f"Games ({year})", ctx.get("total_year_games")))
+ if ctx.get("all_finished_this_year_count"):
+ rows.append(_kv("Finished", ctx.get("all_finished_this_year_count")))
+ rows.append(
+ _kv(f"Finished ({year})", ctx.get("this_year_finished_this_year_count"))
+ )
+
+ def _game_row(label, value, game):
+ return _tr(
+ [
+ _td(label, _CELL),
+ _td([str(value), " (", GameLink(game.id, game.name), ")"]),
+ ]
+ )
+
+ longest_game = ctx.get("longest_session_game")
+ if longest_game and longest_game.id:
+ rows.append(
+ _game_row("Longest session", ctx.get("longest_session_time"), longest_game)
+ )
+ most_sessions_game = ctx.get("highest_session_count_game")
+ if most_sessions_game and most_sessions_game.id:
+ rows.append(
+ _game_row(
+ "Most sessions", ctx.get("highest_session_count"), most_sessions_game
+ )
+ )
+ avg_game = ctx.get("highest_session_average_game")
+ if avg_game and avg_game.id:
+ rows.append(
+ _game_row(
+ "Highest session average", ctx.get("highest_session_average"), avg_game
+ )
+ )
+ first_game = ctx.get("first_play_game")
+ if first_game and first_game.id:
+ rows.append(
+ _tr(
+ [
+ _td("First play", _CELL),
+ _td(
+ [
+ GameLink(first_game.id, first_game.name),
+ f" ({ctx.get('first_play_date')})",
+ ]
+ ),
+ ]
+ )
+ )
+ last_game = ctx.get("last_play_game")
+ if last_game and last_game.id:
+ rows.append(
+ _tr(
+ [
+ _td("Last play", _CELL),
+ _td(
+ [
+ GameLink(last_game.id, last_game.name),
+ f" ({ctx.get('last_play_date')})",
+ ]
+ ),
+ ]
+ )
+ )
+ return _table(rows)
+
+
+def _purchases_table(ctx) -> SafeText:
+ rows = [
+ _kv("Total", ctx.get("all_purchased_this_year_count")),
+ _kv(
+ "Refunded",
+ f"{ctx.get('all_purchased_refunded_this_year_count')} "
+ f"({ctx.get('refunded_percent')}%)",
+ ),
+ _kv(
+ "Dropped",
+ f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)",
+ ),
+ _kv(
+ "Unfinished",
+ f"{ctx.get('purchased_unfinished_count')} "
+ f"({ctx.get('unfinished_purchases_percent')}%)",
+ ),
+ _kv("Backlog Decrease", ctx.get("backlog_decrease_count")),
+ _kv(
+ f"Spendings ({ctx.get('total_spent_currency')})",
+ f"{floatformat(ctx.get('total_spent'))} "
+ f"({floatformat(ctx.get('spent_per_game'))}/game)",
+ ),
+ ]
+ return _table(rows)
+
+
+def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
+ thead = Component(
+ tag_name="thead",
+ children=[_tr([_th(header), _th("Playtime")])],
+ )
+ rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
+ return _table(rows, thead)
+
+
+def _finished_table(purchases) -> SafeText:
+ thead = Component(
+ tag_name="thead",
+ children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
+ )
+ rows = [
+ _tr([_td(_purchase_name(p)), _td(date_filter(p.date_finished, "d/m/Y"))])
+ for p in purchases
+ ]
+ return _table(rows, thead)
+
+
+def _priced_table(purchases, currency) -> SafeText:
+ thead = Component(
+ tag_name="thead",
+ children=[
+ _tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
+ ],
+ )
+ rows = [
+ _tr(
+ [
+ _td(_purchase_name(p)),
+ _td(floatformat(p.converted_price)),
+ _td(date_filter(p.date_purchased, "d/m/Y")),
+ ]
+ )
+ for p in purchases
+ ]
+ return _table(rows, thead)
+
+
+def stats_content(ctx: dict) -> SafeText:
+ year = ctx.get("year")
+ currency = ctx.get("total_spent_currency")
+ sections: list = [
+ _year_dropdown(year, ctx.get("stats_dropdown_year_range")),
+ _h1("Playtime"),
+ _playtime_table(ctx),
+ ]
+
+ months = list(ctx.get("month_playtimes") or [])
+ if months:
+ sections.append(_h1("Playtime per month"))
+ month_rows = [
+ _kv(date_filter(m["month"], "F"), _dur(m["playtime"])) for m in months
+ ]
+ sections.append(_table(month_rows))
+
+ sections += [
+ _h1("Purchases"),
+ _purchases_table(ctx),
+ _h1("Games by playtime"),
+ _two_col_table(
+ "Name",
+ ctx.get("top_10_games_by_playtime") or [],
+ lambda g: GameLink(g.id, g.name),
+ lambda g: _dur(g.total_playtime),
+ ),
+ _h1("Platforms by playtime"),
+ _two_col_table(
+ "Platform",
+ ctx.get("total_playtime_per_platform") or [],
+ lambda item: item["platform_name"],
+ lambda item: _dur(item["playtime"]),
+ ),
+ ]
+
+ all_finished = list(ctx.get("all_finished_this_year") or [])
+ if all_finished:
+ sections += [_h1("Finished"), _finished_table(all_finished)]
+
+ year_finished = list(ctx.get("this_year_finished_this_year") or [])
+ if year_finished:
+ sections += [_h1(f"Finished ({year} games)"), _finished_table(year_finished)]
+
+ bought_finished = list(ctx.get("purchased_this_year_finished_this_year") or [])
+ if bought_finished:
+ sections += [
+ _h1(f"Bought and Finished ({year})"),
+ _finished_table(bought_finished),
+ ]
+
+ unfinished = list(ctx.get("purchased_unfinished") or [])
+ if unfinished:
+ sections += [
+ _h1("Unfinished Purchases"),
+ _priced_table(unfinished, currency),
+ ]
+
+ all_purchased = list(ctx.get("all_purchased_this_year") or [])
+ if all_purchased:
+ sections += [_h1("All Purchases"), _priced_table(all_purchased, currency)]
+
+ return Div(
+ [("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
+ sections,
+ )
diff --git a/games/views/statuschange.py b/games/views/statuschange.py
index 654a7c1..d9053f2 100644
--- a/games/views/statuschange.py
+++ b/games/views/statuschange.py
@@ -1,57 +1,130 @@
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.shortcuts import get_object_or_404
-from django.urls import reverse_lazy
-from django.views.generic import CreateView, DeleteView, ListView, UpdateView
+from django.contrib.auth.decorators import login_required
+from django.core.paginator import Paginator
+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
+from common.components import (
+ A,
+ AddForm,
+ Button,
+ Component,
+ CsrfInput,
+ Div,
+ paginated_table_content,
+)
+from common.layout import render_page
+from common.time import dateformat, local_strftime
from games.forms import GameStatusChangeForm
from games.models import GameStatusChange
-class EditStatusChangeView(LoginRequiredMixin, UpdateView):
- model = GameStatusChange
- form_class = GameStatusChangeForm
- template_name = "add.html"
- context_object_name = "form"
-
- def get_object(self, queryset=None):
- return get_object_or_404(GameStatusChange, id=self.kwargs["statuschange_id"])
-
- def get_success_url(self):
- return reverse_lazy("games:list_platforms")
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["title"] = "Edit Platform"
- return context
+@login_required
+def add_statuschange(request: HttpRequest) -> HttpResponse:
+ form = GameStatusChangeForm(request.POST or None)
+ if form.is_valid():
+ obj = form.save()
+ return redirect("games:view_game", game_id=obj.game.id)
+ return render_page(
+ request, AddForm(form, request=request), title="Add status change"
+ )
-class AddStatusChangeView(LoginRequiredMixin, CreateView):
- model = GameStatusChange
- form_class = GameStatusChangeForm
- template_name = "add.html"
-
- def get_success_url(self):
- return reverse_lazy("games:view_game", kwargs={"pk": self.object.game.id})
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["title"] = "Add status change"
- return context
+@login_required
+def edit_statuschange(request: HttpRequest, statuschange_id: int) -> HttpResponse:
+ statuschange = get_object_or_404(GameStatusChange, id=statuschange_id)
+ form = GameStatusChangeForm(request.POST or None, instance=statuschange)
+ if form.is_valid():
+ form.save()
+ return redirect("games:list_platforms")
+ return render_page(
+ request, AddForm(form, request=request), title="Edit status change"
+ )
-class GameStatusChangeListView(LoginRequiredMixin, ListView):
- model = GameStatusChange
- template_name = "list_purchases.html"
- context_object_name = "status_changes"
- paginate_by = 10
+@login_required
+def list_statuschanges(request: HttpRequest) -> HttpResponse:
+ page_number = request.GET.get("page", 1)
+ limit = request.GET.get("limit", 10)
+ statuschanges = GameStatusChange.objects.select_related("game").all()
+ page_obj = None
+ if int(limit) != 0:
+ paginator = Paginator(statuschanges, limit)
+ page_obj = paginator.get_page(page_number)
+ statuschanges = page_obj.object_list
+ elided_page_range = (
+ page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
+ if page_obj
+ else None
+ )
- def get_queryset(self):
- return GameStatusChange.objects.select_related("game").all()
+ data = {
+ "header_action": None,
+ "columns": ["Game", "Old Status", "New Status", "Timestamp"],
+ "rows": [
+ [
+ sc.game.name,
+ sc.get_old_status_display() if sc.old_status else "-",
+ sc.get_new_status_display(),
+ local_strftime(sc.timestamp, dateformat) if sc.timestamp else "-",
+ ]
+ for sc in statuschanges
+ ],
+ }
+ content = paginated_table_content(
+ data,
+ page_obj=page_obj,
+ elided_page_range=elided_page_range,
+ request=request,
+ )
+ return render_page(request, content, title="Status changes")
-class GameStatusChangeDeleteView(LoginRequiredMixin, DeleteView):
- model = GameStatusChange
- template_name = "gamestatuschange_confirm_delete.html"
+def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText:
+ inner = Div(
+ [],
+ [
+ Component(
+ tag_name="p",
+ children=["Are you sure you want to delete this status change?"],
+ ),
+ Button(
+ [("class", "w-full")], "Delete", color="red", type="submit", size="lg"
+ ),
+ A(
+ [("class", "")],
+ Button([("class", "w-full")], "Cancel", color="gray"),
+ href=reverse("games:view_game", args=[statuschange.game.id]),
+ ),
+ ],
+ )
+ form = Component(
+ tag_name="form",
+ attributes=[("method", "post"), ("class", "dark:text-white")],
+ children=[CsrfInput(request), inner],
+ )
+ 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",
+ )
+ ],
+ [form],
+ )
- def get_success_url(self):
- return reverse_lazy("games:view_game", kwargs={"game_id": self.object.game.id})
+
+@login_required
+def delete_statuschange(request: HttpRequest, pk: int) -> HttpResponse:
+ statuschange = get_object_or_404(GameStatusChange, id=pk)
+ if request.method == "POST":
+ game_id = statuschange.game.id
+ statuschange.delete()
+ return redirect("games:view_game", game_id=game_id)
+ return render_page(
+ request,
+ _delete_statuschange_content(statuschange, request),
+ title="Delete status change",
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 1e5c434..fefafe7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,6 @@ dependencies = [
"django-htmx>=1.18.0,<2",
"django-template-partials>=24.2,<25",
"markdown>=3.6,<4",
- "django-cotton==2.3",
"django-q2>=1.7.4,<2",
"croniter>=5.0.1,<6",
"requests>=2.32.3,<3",
@@ -55,6 +54,15 @@ module-root = ""
[build-system]
requires = ["uv_build>=0.9.26,<0.10.0"]
build-backend = "uv_build"
+[tool.ruff]
+extend-exclude = [
+ # TODO: remove this exclusion once the streak feature is actually
+ # implemented. streak_bruteforce.py is a throwaway exploration script
+ # (it runs django.setup() before importing models, which trips E402);
+ # it's excluded for now rather than contorted to satisfy the linter.
+ "streak_bruteforce.py",
+]
+
[tool.isort]
profile = "black"
diff --git a/tests/test_components.py b/tests/test_components.py
index 5f1e864..48df1bf 100644
--- a/tests/test_components.py
+++ b/tests/test_components.py
@@ -1,143 +1,17 @@
import unittest
-from functools import lru_cache
from unittest.mock import MagicMock, patch
import django
-from django.template import TemplateDoesNotExist
from django.utils.safestring import SafeText, mark_safe
from common import components
from games.models import Platform, Game, Purchase, Session
-class RenderCachedImplTest(unittest.TestCase):
- """Test _render_cached_impl renders templates correctly."""
-
- def test_basic_render(self):
- result = components._render_cached_impl(
- "cotton/icon/play.html",
- '{"slot": "", "title": "Play"}',
- )
- self.assertIn("", result)
-
- def test_slot_marked_safe(self):
- result = components._render_cached_impl(
- "cotton/icon/play.html",
- '{"slot": "bold ", "title": "Play"}',
- )
- self.assertIsInstance(result, SafeText)
-
- def test_different_templates_different_output(self):
- r1 = components._render_cached_impl(
- "cotton/icon/play.html", '{"slot": "", "title": "Play"}',
- )
- r2 = components._render_cached_impl(
- "cotton/icon/delete.html", '{"slot": "", "title": "Delete"}',
- )
- self.assertNotEqual(r1, r2)
-
- def test_nonexistent_template_raises(self):
- with self.assertRaises(TemplateDoesNotExist):
- components._render_cached_impl(
- "cotton/nonexistent.html", '{"slot": "", "title": "X"}',
- )
-
- def test_context_keys_are_sorted(self):
- """Verify sort_keys=True in Component produces consistent JSON."""
- from common.components import Component
- r1 = Component(
- template="cotton/icon/play.html",
- attributes=[("title", "Play"), ("b", "2")],
- )
- r2 = Component(
- template="cotton/icon/play.html",
- attributes=[("b", "2"), ("title", "Play")],
- )
- self.assertEqual(r1, r2)
-
-
-class RenderCachedLRUTest(unittest.TestCase):
- """Test LRU cache behavior of _render_cached when enabled."""
-
- def setUp(self):
- components.enable_cache()
- components._render_cached.cache_clear()
-
- def tearDown(self):
- components._render_cached = components._render_cached_impl
-
- def test_cache_hits_and_misses(self):
- # Call through _render_cached (the cached wrapper), not _render_cached_impl
- components._render_cached(
- "cotton/icon/play.html", '{"slot": "", "title": "Play"}',
- )
- info = components._render_cached.cache_info()
- self.assertEqual(info.hits, 0)
- self.assertEqual(info.misses, 1)
-
- components._render_cached(
- "cotton/icon/play.html", '{"slot": "", "title": "Play"}',
- )
- info = components._render_cached.cache_info()
- self.assertEqual(info.hits, 1)
- self.assertEqual(info.misses, 1)
-
- def test_cache_clear(self):
- components._render_cached_impl(
- "cotton/icon/play.html", '{"slot": "", "title": "Play"}',
- )
- components._render_cached.cache_clear()
- info = components._render_cached.cache_info()
- self.assertEqual(info.currsize, 0)
- self.assertEqual(info.hits, 0)
-
- def test_cache_parameters(self):
- info = components._render_cached.cache_info()
- self.assertEqual(components._render_cached.cache_parameters()["maxsize"], 4096)
-
- def test_different_contexts_different_entries(self):
- # Call through _render_cached (the cached wrapper), not _render_cached_impl
- components._render_cached(
- "cotton/button.html",
- '{"size": "base", "color": "blue", "icon": false, "class": "hover:cursor-pointer", "slot": ""}',
- )
- components._render_cached(
- "cotton/button.html",
- '{"size": "base", "color": "red", "icon": false, "class": "hover:cursor-pointer", "slot": ""}',
- )
- info = components._render_cached.cache_info()
- self.assertEqual(info.currsize, 2)
-
- def test_cache_size_limited(self):
- """After exceeding maxsize, oldest entries are evicted."""
- for i in range(5000):
- components._render_cached_impl(
- f"cotton/icon/play.html",
- f'{{"slot": "", "title": "{i}"}}',
- )
- info = components._render_cached.cache_info()
- self.assertLessEqual(info.currsize, 4096)
-
-
class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent."""
- def setUp(self):
- components.enable_cache()
- components._render_cached.cache_clear()
-
- def tearDown(self):
- components._render_cached = components._render_cached_impl
-
- def test_template_component(self):
- result = components.Component(
- template="cotton/icon/play.html", attributes=[],
- )
- self.assertIn("", result)
-
def test_tag_name_component(self):
result = components.Component(
tag_name="div",
@@ -146,23 +20,34 @@ class ComponentIntegrationTest(unittest.TestCase):
)
self.assertEqual(result, 'hello
')
- def test_repeated_calls_identical(self):
- r1 = components.Component(
- template="cotton/icon/play.html", attributes=[],
- )
- r2 = components.Component(
- template="cotton/icon/play.html", attributes=[],
- )
- self.assertEqual(r1, r2)
- def test_different_components_different(self):
- r1 = components.Component(
- template="cotton/button.html", attributes=[("hx_get", "/url1")],
+class ComponentCacheTest(unittest.TestCase):
+ """Component rendering is memoized via _render_element."""
+
+ def setUp(self):
+ components._render_element.cache_clear()
+
+ def test_identical_components_hit_cache(self):
+ components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
+ misses = components._render_element.cache_info().misses
+ components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
+ info = components._render_element.cache_info()
+ self.assertEqual(info.misses, misses) # no new miss
+ self.assertGreaterEqual(info.hits, 1) # served from cache
+
+ def test_cache_is_bounded(self):
+ self.assertEqual(
+ components._render_element.cache_parameters()["maxsize"], 4096
)
- r2 = components.Component(
- template="cotton/button.html", attributes=[("hx_get", "/url2")],
- )
- self.assertNotEqual(r1, r2)
+
+ def test_safe_and_unsafe_children_do_not_collide(self):
+ """A SafeText "" and a plain "" are equal as strings but must
+ render differently — the cache key must keep them distinct."""
+ safe = components.Component(tag_name="span", children=[mark_safe("x ")])
+ unsafe = components.Component(tag_name="span", children=["x "])
+ self.assertIn("x ", safe)
+ self.assertIn("<b>x</b>", unsafe)
+ self.assertNotEqual(safe, unsafe)
class RandomidDeterministicTest(unittest.TestCase):
@@ -191,7 +76,9 @@ class RandomidDeterministicTest(unittest.TestCase):
def test_output_is_lowercase_alphanum(self):
result = components.randomid(content="test")
- self.assertTrue(all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in result))
+ self.assertTrue(
+ all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in result)
+ )
def test_output_length_is_correct(self):
for length in [5, 10, 15, 20]:
@@ -209,6 +96,7 @@ class RandomidVsOldBehaviorTest(unittest.TestCase):
def _old_random_id(self, seed="", length=10):
from random import choices
from string import ascii_lowercase
+
return seed + "".join(choices(ascii_lowercase, k=length))
def test_old_random_produces_different_ids(self):
@@ -227,13 +115,6 @@ class RandomidVsOldBehaviorTest(unittest.TestCase):
class PopoverDeterministicTest(unittest.TestCase):
"""Test that Popover() produces deterministic HTML output."""
- def setUp(self):
- components.enable_cache()
- components._render_cached.cache_clear()
-
- def tearDown(self):
- components._render_cached = components._render_cached_impl
-
def test_same_popover_same_id(self):
r1 = components.Popover("hello", wrapped_content="hello")
r2 = components.Popover("hello", wrapped_content="hello")
@@ -265,75 +146,33 @@ class PopoverDeterministicTest(unittest.TestCase):
self.assertEqual(r1.encode(), r2.encode())
-class PopoverCacheIntegrationTest(unittest.TestCase):
- """Test that Popover() output works correctly with LRU caching."""
-
- def setUp(self):
- components.enable_cache()
- components._render_cached.cache_clear()
-
- def tearDown(self):
- components._render_cached = components._render_cached_impl
-
- def _get_popover_context(self, popover_content, wrapped_content="", wrapped_classes="", slot=""):
- """Build the context JSON matching the new cotton/popover.html shim."""
- import json
- content = f"{wrapped_content}:{popover_content}:{wrapped_classes}"
- id = components.randomid(content=content)
- context = {
- "id": id,
- "popover_content": popover_content,
- "wrapped_content": wrapped_content,
- "wrapped_classes": wrapped_classes,
- "slot": slot,
- }
- return json.dumps(context, sort_keys=True)
-
- def test_popover_shim_template_is_cached(self):
- ctx_a = self._get_popover_context(popover_content="a", wrapped_content="a")
- ctx_b = self._get_popover_context(popover_content="b", wrapped_content="b")
- components._render_cached("cotton/popover.html", ctx_a)
- components._render_cached("cotton/popover.html", ctx_b)
- info = components._render_cached.cache_info()
- self.assertEqual(info.currsize, 2)
-
- def test_popover_shim_repeated_call_uses_cache(self):
- ctx = self._get_popover_context(popover_content="x", wrapped_content="x")
- for _ in range(5):
- components._render_cached("cotton/popover.html", ctx)
- info = components._render_cached.cache_info()
- self.assertEqual(info.hits, 4)
-
- def test_popover_shim_no_cache_hit_on_first_call(self):
- ctx = self._get_popover_context(popover_content="y", wrapped_content="y")
- components._render_cached("cotton/popover.html", ctx)
- info = components._render_cached.cache_info()
- self.assertEqual(info.hits, 0)
-
-
class TemplatetagRandomidTest(unittest.TestCase):
"""Test games/templatetags/randomid.py produces deterministic IDs."""
def test_same_seed_same_id(self):
from games.templatetags import randomid
+
r1 = randomid.randomid(seed="foo")
r2 = randomid.randomid(seed="foo")
self.assertEqual(r1, r2)
def test_different_seed_different_id(self):
from games.templatetags import randomid
+
r1 = randomid.randomid(seed="foo")
r2 = randomid.randomid(seed="bar")
self.assertNotEqual(r1, r2)
def test_output_length_ten(self):
from games.templatetags import randomid
+
for seed in ["a", "hello", "test1234"]:
result = randomid.randomid(seed=seed)
self.assertEqual(len(result), 10)
def test_empty_seed_returns_hash(self):
from games.templatetags import randomid
+
result = randomid.randomid()
self.assertEqual(len(result), 10)
self.assertTrue(all(c in "abcdef0123456789" for c in result))
@@ -342,13 +181,6 @@ class TemplatetagRandomidTest(unittest.TestCase):
class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly."""
- def setUp(self):
- components.enable_cache()
- components._render_cached.cache_clear()
-
- def tearDown(self):
- components._render_cached = components._render_cached_impl
-
def test_div_returns_safe_text(self):
result = components.Div([("class", "x")], "hello")
self.assertIsInstance(result, SafeText)
@@ -362,7 +194,7 @@ class ComponentReturnTypeTest(unittest.TestCase):
def test_div_no_args(self):
result = components.Div(children="test")
self.assertIsInstance(result, SafeText)
- self.assertIn(' test
', result)
+ self.assertIn("test
", result)
def test_a_returns_safe_text(self):
result = components.A([], "link")
@@ -374,14 +206,15 @@ 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"):
result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self):
result = components.A([], "link")
- self.assertIn('link ', result)
- self.assertNotIn('href=', result)
+ self.assertIn("link ", result)
+ self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
@@ -416,12 +249,14 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
("A", components.A(href="/foo", children=["link"])),
("Button", components.Button([], "click")),
("Div", components.Div([], ["hello"])),
- ("Form", components.Form(children=["x"])),
("Input", components.Input()),
("ButtonGroup", components.ButtonGroup([])),
- ("ButtonGroup with buttons", components.ButtonGroup(
- [{"href": "/", "slot": components.Icon("edit")}]
- )),
+ (
+ "ButtonGroup with buttons",
+ components.ButtonGroup(
+ [{"href": "/", "slot": components.Icon("edit")}]
+ ),
+ ),
("SearchField", components.SearchField()),
("PriceConverted", components.PriceConverted(["27 CZK"])),
("H1", components.H1(["Title"])),
@@ -435,7 +270,8 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_button_with_icon_children_not_escaped(self):
result = components.Button(
- icon=True, size="xs",
+ icon=True,
+ size="xs",
children=[components.Icon("play"), "LOG"],
)
self.assertTrue(str(result).startswith("", result)
self.assertIn("", result)
@@ -523,13 +359,6 @@ class ComponentEdgeCasesTest(unittest.TestCase):
class IconTest(unittest.TestCase):
"""Test Icon() component function."""
- def setUp(self):
- components.enable_cache()
- components._render_cached.cache_clear()
-
- def tearDown(self):
- components._render_cached = components._render_cached_impl
-
def test_valid_icon_renders_svg(self):
result = components.Icon("play")
self.assertIsInstance(result, SafeText)
@@ -550,33 +379,12 @@ class IconTest(unittest.TestCase):
self.assertIsInstance(result, SafeText)
-class FormInputTest(unittest.TestCase):
- """Test Form(), Input(), and Div() functions."""
-
- def test_form_default_method_get(self):
- result = components.Form()
- self.assertIn('method="get"', result)
- self.assertIn('