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}" 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}" - 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 ( + '" + ) + + +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"" + 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 ( + '
    ' + '" + "
    " + ) 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"""""") + + +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("\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' loading indicator\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 @@ - - -
    -
    -
    - {% csrf_token %} - {% for field in form %} -
    - {{ field.label_tag }} - {% if field.name == "note" %} - {{ field }} - {% else %} - {{ field }} - {% endif %} - {% if field.name == "timestamp_start" or field.name == "timestamp_end" %} - - Set to now - Toggle text - - Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %} - - - {% endif %} -
    - {% endfor %} -
    - - Submit - -
    -
    - {{ additional_row }} -
    -
    -
    -
    -
    -
    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 %} -
    -
    -
    - {% csrf_token %} - {{ form.as_div }} -
    - - Submit - -
    -
    - {{ additional_row }} -
    -
    -
    -
    -{% 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 %} - - - - - - - loading indicator -
    - {% 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 %} -
    -
    - {% csrf_token %} -
    -

    Are you sure you want to delete this status change?

    - Delete - - Cancel - -
    -
    -
    -
    - 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 %} - - - - - - - - - - - {% for session in dataset %} - {% partialdef session-row inline=True %} - - - - - - - {% endpartialdef %} - {% endfor %} - -
    NameDuration
    - - - {{ session.game.name }} - - - {{ session.duration_formatted }}
    - {% 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 %} - 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 }}? -

    -
    - {% csrf_token %} -

    - This will permanently delete this game and all associated data: -

    -
      - {% if session_count %}
    • {{ session_count }} session(s)
    • {% endif %} - {% if purchase_count %}
    • {{ purchase_count }} purchase(s)
    • {% endif %} - {% if playevent_count %}
    • {{ playevent_count }} play event(s)
    • {% endif %} - {% if not session_count and not purchase_count and not playevent_count %}
    • No associated data
    • {% endif %} -
    -

    - This action cannot be undone. -

    -
    - Delete - Cancel -
    -
    -{% 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 @@ -
    -
    - -
    -
    \ 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? -

    -
    - {% csrf_token %} -

    - Games will be marked as abandoned. -

    -
    - Refund - Cancel -
    -
    -{% 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

    -
    - - {% csrf_token %} - {{ form.as_table }} - - - - - -
    - -
    -
    -
    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 %}{% endfor %} - - - - {% for row in rows %}{% python_table_row data=row %}{% endfor %} - -
    {{ column }}
    -
    - {% if page_obj and elided_page_range %} - - {% 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

    - - - - - - - - - - - - - - - {% if total_games %} - - - - - {% endif %} - - - - - {% if all_finished_this_year_count %} - - - - - {% endif %} - - - - - {% if longest_session_game.id %} - - - - - {% endif %} - {% if highest_session_count_game.id %} - - - - - {% endif %} - {% if highest_session_average_game.id %} - - - - - {% endif %} - {% if first_play_game.id %} - - - - - {% endif %} - {% if last_play_game.id %} - - - - - {% endif %} - -
    Hours{{ total_hours }}
    Sessions{{ total_sessions }}
    Days{{ unique_days }} ({{ unique_days_percent }}%)
    Games{{ total_games }}
    Games ({{ year }}){{ total_year_games }}
    Finished{{ all_finished_this_year_count }}
    Finished ({{ year }}){{ this_year_finished_this_year_count }}
    Longest session - {{ longest_session_time }} () -
    Most sessions - {{ highest_session_count }} () -
    Highest session average - {{ highest_session_average }} () -
    First play - ({{ first_play_date }}) -
    Last play - ({{ last_play_date }}) -
    - {% if month_playtimes %} -

    Playtime per month

    - - - {% for month in month_playtimes %} - - - - - {% endfor %} - -
    {{ month.month | date:"F" }}{{ month.playtime | format_duration }}
    - {% 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

    - - - - - - - - - {% for game in top_10_games_by_playtime %} - - - - - {% endfor %} - -
    NamePlaytime
    - - {{ game.total_playtime | format_duration }}
    -

    Platforms by playtime

    - - - - - - - - - {% for item in total_playtime_per_platform %} - - - - - {% endfor %} - -
    PlatformPlaytime
    {{ item.platform_name }}{{ item.playtime | format_duration }}
    - {% if all_finished_this_year %} -

    Finished

    - - - - - - - - - {% for purchase in all_finished_this_year %} - - - - - {% endfor %} - -
    NameDate
    {% partial purchase-name %}{{ purchase.date_finished | date:"d/m/Y" }}
    - {% endif %} - {% if this_year_finished_this_year %} -

    Finished ({{ year }} games)

    - - - - - - - - - {% for purchase in this_year_finished_this_year %} - - - - - {% endfor %} - -
    NameDate
    {% partial purchase-name %}{{ purchase.date_finished | date:"d/m/Y" }}
    - {% endif %} - {% if purchased_this_year_finished_this_year %} -

    Bought and Finished ({{ year }})

    - - - - - - - - - {% for purchase in purchased_this_year_finished_this_year %} - - - - - {% endfor %} - -
    NameDate
    {% partial purchase-name %}{{ purchase.date_finished | date:"d/m/Y" }}
    - {% endif %} - {% if purchased_unfinished %} -

    Unfinished Purchases

    - - - - - - - - - - {% for purchase in purchased_unfinished %} - - - - - - {% endfor %} - -
    NamePrice ({{ total_spent_currency }})Date
    {% partial purchase-name %}{{ purchase.converted_price | floatformat }}{{ purchase.date_purchased | date:"d/m/Y" }}
    - {% endif %} - {% if all_purchased_this_year %} -

    All Purchases

    - - - - - - - - - - {% for purchase in all_purchased_this_year %} - - - - - - {% endfor %} - -
    NamePrice ({{ total_spent_currency }})Date
    {% partial purchase-name %}{{ purchase.converted_price | floatformat }}{{ purchase.date_purchased | date:"d/m/Y" }}
    - {% 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 %} -
    -
    - Played -
    - - - - -
    -
    - - - - -
    - 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 = """
    + Played +
    + + + + +
    +
    """ + + +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('")[0] def test_simple_table_renders_list_rows(self): """Verify list-style rows render as with + .""" - from django.template.loader import render_to_string - result = render_to_string( - "simple_table.html", - { - "columns": ["Game", "Started", "Ended"], - "rows": [["Game1", "2025-01-01", "2025-03-01"]], - "header_action": None, - "page_obj": None, - "elided_page_range": None, - }, + result = str( + components.SimpleTable( + columns=["Game", "Started", "Ended"], + rows=[["Game1", "2025-01-01", "2025-03-01"]], + ) ) - # body rows (not thead) - tbody = result.split("")[0] + tbody = self._tbody(result) self.assertIn(" - self.assertIn("th scope=\"row\"", tbody) - # subsequent cells are + # first cell is , subsequent cells are + self.assertIn('th scope="row"', tbody) self.assertIn(".""" - from django.template.loader import render_to_string - result = render_to_string( - "simple_table.html", - { - "columns": ["Game", "Started"], - "rows": [], - "header_action": None, - "page_obj": None, - "elided_page_range": None, - }, - ) + result = str(components.SimpleTable(columns=["Game", "Started"], rows=[])) self.assertIn("")[0] + tbody = self._tbody(result) self.assertNotIn("")[0] + tbody = self._tbody(result) self.assertIn("GameA", tbody) self.assertIn("GameB", tbody) - self.assertIn(" elements - self.assertEqual(tbody.count("")[0] - self.assertIn('id="session-row-1"', tbody) - self.assertIn("device-changed", tbody) - self.assertIn("th scope=\"row\"", tbody) - self.assertIn("Game1", tbody) - self.assertIn("2025-01-01", tbody) - self.assertIn("2025-03-01", result) - - def test_simple_table_empty_rows(self): - """Verify empty rows list renders empty .""" - from django.template.loader import render_to_string - result = render_to_string( - "simple_table.html", - { - "columns": ["Game", "Started"], - "rows": [], - "header_action": None, - "page_obj": None, - "elided_page_range": None, - }, - ) - self.assertIn("")[0] - self.assertNotIn("")[0] - self.assertIn("GameA", tbody) - self.assertIn("GameB", tbody) - self.assertIn(".""" - from django.template.loader import render_to_string from django.utils.safestring import mark_safe - result = render_to_string( - "simple_table.html", - { - "columns": ["Game", "Started"], - "rows": [["Game1", "2025-01-01"]], - "header_action": mark_safe('Add'), - "page_obj": None, - "elided_page_range": None, - }, + + result = str( + components.SimpleTable( + columns=["Game", "Started"], + rows=[["Game1", "2025-01-01"]], + header_action=mark_safe('Add'), + ) ) self.assertIn("")[0] + tbody = self._tbody(result) self.assertIn('id="session-row-1"', tbody) self.assertIn("device-changed", tbody) - self.assertIn("th scope=\"row\"", tbody) + self.assertIn('th scope="row"', tbody) self.assertIn("Game1", tbody) self.assertIn("2025-01-01", tbody) diff --git a/tests/test_middleware_integration.py b/tests/test_middleware_integration.py index 7f2c443..cf69427 100644 --- a/tests/test_middleware_integration.py +++ b/tests/test_middleware_integration.py @@ -26,9 +26,9 @@ class MiddlewareIntegrationTest(TestCase): self.client = Client() self.user = self._create_user() self.client.force_login(self.user) - pl = Platform(name="Test Platform") - pl.save() - self.game = Game(name="Test Game", platform=pl) + self.platform = Platform(name="Test Platform") + self.platform.save() + self.game = Game(name="Test Game", platform=self.platform) self.game.save() def test_non_htmx_request_with_message_gets_hx_trigger(self): @@ -82,7 +82,7 @@ class MiddlewareIntegrationTest(TestCase): """ purchase = Purchase.objects.create( date_purchased=datetime(2023, 1, 1), - platform=Platform.objects.first() or pl, + platform=self.platform, ) purchase.games.set([self.game]) response = self.client.post( diff --git a/tests/test_paths_return_200.py b/tests/test_paths_return_200.py index 87309c7..94ca181 100644 --- a/tests/test_paths_return_200.py +++ b/tests/test_paths_return_200.py @@ -6,7 +6,7 @@ from django.test import TestCase from django.urls import reverse from django.contrib.auth.models import User -from games.models import Game, Platform, Purchase, Session +from games.models import Game, Platform, Purchase ZONEINFO = ZoneInfo(settings.TIME_ZONE) diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py new file mode 100644 index 0000000..9c3de7b --- /dev/null +++ b/tests/test_rendered_pages.py @@ -0,0 +1,291 @@ +"""Rendered-HTML assertions for pages converted to the Python layout/components. + +These go beyond `test_paths_return_200`: they assert that the `Page()` document +wrapper and the Python component bodies emit the right structure, and — most +importantly — that nothing is double-escaped (the recurring failure mode when a +`SafeText` loses its safe marker and renders as `<tag>`). +""" + +from datetime import datetime +from zoneinfo import ZoneInfo + +from django.conf import settings +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + +from games.models import Game, GameStatusChange, Platform, Purchase, Session + +ZONEINFO = ZoneInfo(settings.TIME_ZONE) + +# If any of these appear in output, a SafeText lost its safe marker somewhere. +_ESCAPED_TAG_MARKERS = [ + "<a", + "<div", + "<span", + "<button", + "<input", + "<li", +] + + +class RenderedPagesTest(TestCase): + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="testuser", email="test@example.com", password="testpass" + ) + self.client.force_login(self.user) + self.platform = Platform.objects.create(name="Test Platform", icon="test") + self.game = Game.objects.create(name="Test Game", platform=self.platform) + self.purchase = Purchase.objects.create( + date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO), + platform=self.platform, + ) + self.purchase.games.add(self.game) + self.session = Session.objects.create( + game=self.game, + timestamp_start=datetime(2022, 9, 26, 15, 0, tzinfo=ZONEINFO), + timestamp_end=datetime(2022, 9, 26, 16, 0, tzinfo=ZONEINFO), + ) + + def get(self, url_name, *args): + return self.client.get(reverse(url_name, args=args), follow=True) + + def assertNoEscapedTags(self, html): + for marker in _ESCAPED_TAG_MARKERS: + self.assertNotIn( + marker, html, f"Found double-escaped markup ({marker!r}) in output" + ) + + # --- layout wrapper ------------------------------------------------------ + + def test_page_layout_wrapper(self): + """A converted page is wrapped in the full Page() document.""" + html = self.get("games:list_playevents").content.decode() + for marker in [ + "", + "", + ]: + self.assertIn(marker, html) + self.assertIn("Timetracker - Manage play events", html) + + # --- list pages ---------------------------------------------------------- + + def test_list_pages_render_table_unescaped(self): + for url_name in [ + "games:list_games", + "games:list_purchases", + "games:list_sessions", + "games:list_platforms", + "games:list_devices", + "games:list_playevents", + ]: + with self.subTest(url_name=url_name): + html = self.get(url_name).content.decode() + self.assertIn("", html) + self.assertNoEscapedTags(html) + + def test_add_session_form_has_timestamp_helpers(self): + html = self.get("games:add_session").content.decode() + self.assertIn("add_session.js", html) + for marker in [ + "Set to now", + "Toggle text", + "Copy start value to end", + "Copy end value to start", + 'data-target="timestamp_start"', + 'data-type="now"', + 'hx-boost="false"', + ]: + self.assertIn(marker, html) + self.assertNoEscapedTags(html) + + # --- detail pages -------------------------------------------------------- + + def test_view_game(self): + html = self.get("games:view_game", self.game.id).content.decode() + for marker in [ + 'id="game-info"', + "font-bold font-serif", + self.game.name, + "Total hours played", # stat popover tooltip + 'id="popover-hours"', + "Original year", + "Status", + "Played", + "Platform", + 'id="history-container"', + "status-changed from:body", + "createPlayEvent", # the played-row Alpine dropdown script + 'hx-target="#global-modal-container"', # delete trigger + "Purchases", + "Sessions", + "Play Events", + "History", + ]: + self.assertIn(marker, html) + self.assertNoEscapedTags(html) + self.assertEqual(html.count("")) + + def test_view_game_empty_sections(self): + """A game with no sessions/purchases/etc shows the empty messages.""" + lonely = Game.objects.create(name="Lonely Game", platform=self.platform) + html = self.get("games:view_game", lonely.id).content.decode() + for marker in [ + "No purchases yet.", + "No sessions yet.", + "No play events yet.", + ]: + self.assertIn(marker, html) + self.assertNoEscapedTags(html) + + # --- HTMX fragments ------------------------------------------------------ + + def test_delete_game_confirmation_modal(self): + html = self.get("games:delete_game_confirmation", self.game.id).content.decode() + # A fragment (no full-page layout). + self.assertNotIn("", html) + self.assertIn('id="delete-game-confirmation-modal"', html) + self.assertIn("hx-post", html) + self.assertIn(self.game.name, html) + self.assertIn("session(s)", html) # seeded session + self.assertIn("purchase(s)", html) # seeded purchase + self.assertNoEscapedTags(html) + + def test_refund_confirmation_modal(self): + html = self.get( + "games:refund_purchase_confirmation", self.purchase.id + ).content.decode() + self.assertIn('id="refund-confirmation-modal"', html) + self.assertIn(f"#purchase-row-{self.purchase.id}", html) + self.assertIn("Refund", html) + self.assertNoEscapedTags(html) + + def test_session_row_fragment_via_htmx(self): + # The inline "finish session" endpoint returns a fragment. + resp = self.client.get( + reverse("games:list_sessions_end_session", args=[self.session.id]), + HTTP_HX_REQUEST="true", + ) + html = resp.content.decode() + self.assertTrue(html.lstrip().startswith("", # full Page() layout + "Please log in to continue", + "csrfmiddlewaretoken", + 'type="submit"', + 'value="Login"', + "", + ]: + self.assertIn(marker, html) + self.assertIn("Timetracker - Login", html) + self.assertNoEscapedTags(html) + + # --- stats --------------------------------------------------------------- + + def test_stats_alltime(self): + html = self.get("games:stats_alltime").content.decode() + for marker in [ + 'id="yearSelect"', + "responsive-table", + "Playtime", + "Purchases", + "Games by playtime", + "Platforms by playtime", + ]: + self.assertIn(marker, html) + self.assertNoEscapedTags(html) + self.assertEqual(html.count("")) + + def test_stats_by_year(self): + year = self.session.timestamp_start.year + html = self.get("games:stats_by_year", year).content.decode() + # The seeded game/session/purchase should surface in the year view. + self.assertIn("Playtime per month", html) + self.assertIn(self.game.name, html) + self.assertNoEscapedTags(html) + self.assertEqual(html.count("")) + + def test_view_purchase(self): + html = self.get("games:view_purchase", self.purchase.id).content.decode() + for marker in [ + "dark:text-white max-w-sm", + "font-bold font-serif", + "Owned on", + "Price per game:", + "decoration-dotted underline", + "Games included in this purchase:", + "
      ", + "
    • ", + ]: + self.assertIn(marker, html) + self.assertNoEscapedTags(html) + # The Python builder emits well-formed, balanced markup. + self.assertEqual(html.count("")) diff --git a/timetracker/settings.py b/timetracker/settings.py index 07130f9..aaec6d8 100644 --- a/timetracker/settings.py +++ b/timetracker/settings.py @@ -40,7 +40,6 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "template_partials", "django_htmx", - "django_cotton", "django_q", ] diff --git a/timetracker/urls.py b/timetracker/urls.py index 1ea14f7..901ccd5 100644 --- a/timetracker/urls.py +++ b/timetracker/urls.py @@ -21,11 +21,12 @@ from django.urls import include, path from django.views.generic import RedirectView from games.api import api +from games.views.auth import LoginView urlpatterns = [ path("", RedirectView.as_view(url="/tracker")), path("api/", api.urls), - path("login/", auth_views.LoginView.as_view(), name="login"), + path("login/", LoginView.as_view(), name="login"), path("logout/", auth_views.LogoutView.as_view(), name="logout"), path("tracker/", include("games.urls")), ] diff --git a/uv.lock b/uv.lock index af9012f..8d2e05c 100644 --- a/uv.lock +++ b/uv.lock @@ -150,18 +150,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/b5/814ed98bd21235c116fd3436a7ed44d47560329a6d694ec8aac2982dbb93/django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d", size = 8338791, upload-time = "2026-01-06T18:55:46.175Z" }, ] -[[package]] -name = "django-cotton" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/3a/1cc607c2688ac3690775ed918b114de8dd70b6d76a3a328af9dcba1c7c31/django_cotton-2.3.0.tar.gz", hash = "sha256:603bf3a0548c9eb069f4f9d30424bf6ad91a3cebc52c497749a7da4dc4b73426", size = 27450, upload-time = "2025-11-10T07:17:51.45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/b0/ad797da10f80c58a6c0c1c16502427e23a894c27f0b80ac13a36218c246c/django_cotton-2.3.0-py3-none-any.whl", hash = "sha256:a9cb9669f41e21cc9286128ada4613aaf0e2e9d837cab8da3fdb99cbbe5e78c5", size = 26991, upload-time = "2025-11-10T07:17:50.196Z" }, -] - [[package]] name = "django-debug-toolbar" version = "4.4.6" @@ -827,7 +815,6 @@ source = { editable = "." } dependencies = [ { name = "croniter" }, { name = "django" }, - { name = "django-cotton" }, { name = "django-htmx" }, { name = "django-ninja" }, { name = "django-q2" }, @@ -858,7 +845,6 @@ dev = [ requires-dist = [ { name = "croniter", specifier = ">=5.0.1,<6" }, { name = "django", specifier = ">6.0" }, - { name = "django-cotton", specifier = "==2.3" }, { name = "django-htmx", specifier = ">=1.18.0,<2" }, { name = "django-ninja", specifier = ">=1.6.2" }, { name = "django-q2", specifier = ">=1.7.4,<2" },