13 Commits

Author SHA1 Message Date
c2f1d8fe0a add backend functionality
All checks were successful
Django CI/CD / test (push) Successful in 50s
Django CI/CD / build-and-push (push) Has been skipped
2024-09-03 15:30:51 +02:00
cd3e400297 add links to add a new X to: game, edition, purchase, session, device, platform
All checks were successful
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Successful in 2m5s
2024-09-03 15:27:04 +02:00
c738245783 Properly display non-game type names
All checks were successful
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 1m55s
2024-09-02 23:52:28 +02:00
57184ceea0 add one more breakpoint to better utilize smaller screens
All checks were successful
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m56s
2024-09-02 23:44:18 +02:00
c2b9409562 update styles
All checks were successful
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-09-02 20:14:52 +02:00
e067e65bce linkify game, edition, purchase, session references
Some checks failed
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Has been cancelled
also add link styles for links in a table row
2024-09-02 20:04:21 +02:00
b8258e2937 replace slippers with django-cotton
All checks were successful
Django CI/CD / test (push) Successful in 59s
Django CI/CD / build-and-push (push) Successful in 2m4s
main reason: slippers cannot pass request via context inside its
components, making it annoying to use template takes that take request.
more reasons: not actively worked on, no named slots, having to define
components in components.yaml + new components do not get registered
without restarting server
2024-09-02 17:43:41 +02:00
9af4c79947 improve game view
All checks were successful
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-19 21:58:43 +02:00
d8b8182b91 fix table top rounding 2024-08-13 08:36:40 +02:00
2fd44c1f53 separate views out 2/2
All checks were successful
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-12 21:52:26 +02:00
c3f99d124c update base.css 2024-08-12 21:42:56 +02:00
51f5b9fceb update ruff path 2024-08-12 21:42:47 +02:00
973f4416de separate views out 1/2 2024-08-12 21:42:34 +02:00
41 changed files with 1477 additions and 1078 deletions

View File

@ -15,4 +15,6 @@ repos:
rev: v1.34.0
hooks:
- id: djlint-reformat-django
args: ["--ignore", "H011"]
- id: djlint-django
args: ["--ignore", "H011"]

View File

@ -13,7 +13,7 @@
"source.organizeImports": "explicit"
},
},
"ruff.path": ["/nix/store/s3q6qc2954x62bkcs9dwaxyiqchl7j01-ruff-0.5.6/bin/ruff"],
"ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"],
"tailwind-fold.supportedLanguages": [
"html",
"typescriptreact",

View File

@ -1,8 +1,9 @@
from random import choices
from string import ascii_lowercase
from typing import Any
from typing import Any, Callable
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import mark_safe
@ -17,16 +18,82 @@ def Popover(
result = mark_safe(
str(content)
+ render_to_string(
"components/popover.html",
"cotton/popover.html",
{
"id": id,
"children": popover_content,
"slot": popover_content,
},
)
)
return result
HTMLAttribute = tuple[str, str]
HTMLTag = str
def Component(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
template: str = "",
tag_name: str = "",
) -> HTMLTag:
if not tag_name and not template:
raise ValueError("One of template or tag_name is required.")
if isinstance(children, str):
children = [children]
childrenBlob = "\n".join(children)
attributesList = [f'{name} = "{value}"' for name, value in attributes]
attributesBlob = " ".join(attributesList)
tag: str = ""
if tag_name != "":
tag = f"<a {attributesBlob}>{childrenBlob}</a>"
elif template != "":
tag = render_to_string(
template,
{name: value for name, value in attributes} | {"slot": "\n".join(children)},
)
return mark_safe(tag)
def A(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "",
):
"""
Returns the HTML tag "a".
"url" can either be:
- URL (string)
- path name passed to reverse() (string)
- function
"""
additional_attributes = []
if url:
if type(url) is str:
try:
url_result = reverse(url)
except NoReverseMatch:
url_result = url
elif callable(url):
url_result = url()
else:
raise TypeError("'url' is neither str nor function.")
additional_attributes = [("href", url_result)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
template="cotton/button.html", attributes=attributes, children=children
)
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.

View File

@ -1,78 +0,0 @@
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 render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import truncate_with_popover
from games.models import Game
from games.views import dateformat
@login_required
def list_games(request: HttpRequest) -> 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")
page_obj = None
if int(limit) != 0:
paginator = Paginator(games, limit)
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
),
"data": {
"columns": [
"Name",
"Sort Name",
"Year",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
truncate_with_popover(game.name),
truncate_with_popover(
game.sort_name
if game.sort_name is not None and game.name != game.sort_name
else "(identical)"
),
game.year_released,
game.wikidata,
game.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_game", args=[game.pk]),
"text": "Edit",
},
{
"href": reverse("delete_game", args=[game.pk]),
"text": "Delete",
"color": "red",
},
]
},
),
]
for game in games
],
},
}
return render(request, "list_purchases.html", context)

View File

@ -1,100 +0,0 @@
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 render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import truncate_with_popover
from games.models import Purchase
from games.views import dateformat
@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")
page_obj = None
if int(limit) != 0:
paginator = Paginator(purchases, limit)
page_obj = paginator.get_page(page_number)
purchases = page_obj.object_list
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": {
"columns": [
"Name",
"Platform",
"Price",
"Currency",
"Infinite",
"Purchased",
"Refunded",
"Finished",
"Dropped",
"Created",
"Actions",
],
"rows": [
[
truncate_with_popover(purchase.edition.name),
purchase.platform,
purchase.price,
purchase.price_currency,
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse(
"edit_purchase", args=[purchase.pk]
),
"text": "Edit",
},
{
"href": reverse(
"delete_purchase", args=[purchase.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
},
}
return render(request, "list_purchases.html", context)

View File

@ -1,93 +0,0 @@
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 render
from django.template.loader import render_to_string
from django.urls import reverse
from common.time import format_duration
from common.utils import truncate_with_popover
from games.models import Session
from games.views import (
dateformat,
datetimeformat,
durationformat,
durationformat_manual,
timeformat,
)
@login_required
def list_sessions(request: HttpRequest) -> 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")
page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
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": {
"columns": [
"Name",
"Date",
"Duration",
"Duration (manual)",
"Device",
"Created",
"Actions",
],
"rows": [
[
truncate_with_popover(session.purchase.edition.name),
f"{session.timestamp_start.strftime(datetimeformat)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
else "-"
),
(
format_duration(session.duration_manual, durationformat_manual)
if session.duration_manual
else "-"
),
session.device,
session.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_session", args=[session.pk]),
"text": "Edit",
},
{
"href": reverse(
"delete_session", args=[session.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for session in sessions
],
},
}
return render(request, "list_purchases.html", context)

View File

@ -1386,12 +1386,16 @@ input:checked + .toggle-bg {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.ml-1 {
margin-left: 0.25rem;
.me-2 {
margin-inline-end: 0.5rem;
}
.mr-4 {
@ -1402,6 +1406,10 @@ input:checked + .toggle-bg {
margin-inline-start: 0px;
}
.ms-2 {
margin-inline-start: 0.5rem;
}
.ms-2\.5 {
margin-inline-start: 0.625rem;
}
@ -1463,10 +1471,6 @@ input:checked + .toggle-bg {
height: 0.625rem;
}
.h-3 {
height: 0.75rem;
}
.h-4 {
height: 1rem;
}
@ -1527,10 +1531,6 @@ input:checked + .toggle-bg {
width: 16rem;
}
.w-7 {
width: 1.75rem;
}
.w-full {
width: 100%;
}
@ -1689,12 +1689,6 @@ input:checked + .toggle-bg {
margin-left: calc(-1px * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
@ -1748,10 +1742,6 @@ input:checked + .toggle-bg {
border-radius: 0.25rem;
}
.rounded-full {
border-radius: 9999px;
}
.rounded-lg {
border-radius: 0.5rem;
}
@ -1826,6 +1816,11 @@ input:checked + .toggle-bg {
border-color: rgb(220 215 254 / var(--tw-border-opacity));
}
.bg-blue-100 {
--tw-bg-opacity: 1;
background-color: rgb(225 239 254 / var(--tw-bg-opacity));
}
.bg-blue-700 {
--tw-bg-opacity: 1;
background-color: rgb(26 86 219 / var(--tw-bg-opacity));
@ -1865,11 +1860,6 @@ input:checked + .toggle-bg {
background-color: rgb(5 122 85 / var(--tw-bg-opacity));
}
.bg-violet-600 {
--tw-bg-opacity: 1;
background-color: rgb(124 58 237 / var(--tw-bg-opacity));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@ -1900,6 +1890,11 @@ input:checked + .toggle-bg {
padding-right: 0.5rem;
}
.px-2\.5 {
padding-left: 0.625rem;
padding-right: 0.625rem;
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
@ -1920,6 +1915,11 @@ input:checked + .toggle-bg {
padding-right: 1.5rem;
}
.py-0\.5 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
@ -1961,12 +1961,12 @@ input:checked + .toggle-bg {
text-align: center;
}
.align-top {
vertical-align: top;
.text-right {
text-align: right;
}
.font-condensed {
font-family: IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
.align-top {
vertical-align: top;
}
.font-mono {
@ -2026,6 +2026,10 @@ input:checked + .toggle-bg {
font-weight: 700;
}
.font-extrabold {
font-weight: 800;
}
.font-medium {
font-weight: 500;
}
@ -2050,15 +2054,28 @@ input:checked + .toggle-bg {
line-height: 2.25rem;
}
.leading-none {
line-height: 1;
}
.leading-tight {
line-height: 1.25;
}
.tracking-tight {
letter-spacing: -0.025em;
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(28 100 242 / var(--tw-text-opacity));
}
.text-blue-800 {
--tw-text-opacity: 1;
color: rgb(30 66 159 / var(--tw-text-opacity));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
@ -2207,10 +2224,6 @@ input:checked + .toggle-bg {
max-width: 20ch;
}
.min-w-30char {
min-width: 30ch;
}
.\[a-zA-Z\:\\-\] {
a-z-a--z: \-;
}
@ -2525,11 +2538,6 @@ textarea:disabled:is(.dark *) {
background-color: rgb(240 82 82 / var(--tw-bg-opacity));
}
.hover\:bg-violet-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(109 40 217 / var(--tw-bg-opacity));
}
.hover\:bg-white:hover {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@ -2616,11 +2624,6 @@ textarea:disabled:is(.dark *) {
--tw-ring-color: rgb(4 108 78 / var(--tw-ring-opacity));
}
.focus\:ring-violet-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
@ -2629,10 +2632,6 @@ textarea:disabled:is(.dark *) {
--tw-ring-offset-color: #C3DDFD;
}
.focus\:ring-offset-violet-200:focus {
--tw-ring-offset-color: #ddd6fe;
}
.group:hover .group-hover\:absolute {
position: absolute;
}
@ -2722,6 +2721,11 @@ textarea:disabled:is(.dark *) {
border-color: transparent;
}
.dark\:bg-blue-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(195 221 253 / var(--tw-bg-opacity));
}
.dark\:bg-blue-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(28 100 242 / var(--tw-bg-opacity));
@ -2765,6 +2769,11 @@ textarea:disabled:is(.dark *) {
color: rgb(63 131 248 / var(--tw-text-opacity));
}
.dark\:text-blue-800:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(30 66 159 / var(--tw-text-opacity));
}
.dark\:text-gray-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
@ -2790,11 +2799,6 @@ textarea:disabled:is(.dark *) {
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.dark\:text-slate-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
.dark\:text-slate-600:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
@ -2880,6 +2884,11 @@ textarea:disabled:is(.dark *) {
--tw-ring-color: rgb(63 131 248 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(30 66 159 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-gray-600:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
@ -2891,10 +2900,6 @@ textarea:disabled:is(.dark *) {
}
@media (min-width: 640px) {
.sm\:inline {
display: inline;
}
.sm\:table-cell {
display: table-cell;
}
@ -2920,22 +2925,6 @@ textarea:disabled:is(.dark *) {
padding-right: 1rem;
}
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 {
padding-left: 0.5rem;
}
.sm\:pl-4 {
padding-left: 1rem;
}
.sm\:pl-6 {
padding-left: 1.5rem;
}
.sm\:decoration-2 {
text-decoration-thickness: 2px;
}
@ -3059,6 +3048,12 @@ textarea:disabled:is(.dark *) {
}
}
@media (min-width: 1280px) {
.xl\:max-w-screen-xl {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.\32xl\:max-w-screen-2xl {
max-width: 1536px;
@ -3074,6 +3069,10 @@ textarea:disabled:is(.dark *) {
--tw-space-x-reverse: 1;
}
.rtl\:text-left:where([dir="rtl"], [dir="rtl"] *) {
text-align: left;
}
.rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) {
text-align: right;
}
@ -3087,3 +3086,15 @@ textarea:disabled:is(.dark *) {
border-start-end-radius: 0.5rem;
border-end-end-radius: 0.5rem;
}
.\[\&_a\]\:underline a {
text-decoration-line: underline;
}
.\[\&_a\]\:decoration-2 a {
text-decoration-thickness: 2px;
}
.\[\&_a\]\:underline-offset-4 a {
text-underline-offset: 4px;
}

View File

@ -1,9 +0,0 @@
components:
gamelink: "components/game_link.html"
popover: "components/popover.html"
table: "components/table.html"
table_row: "components/table_row.html"
table_td: "components/table_td.html"
simple_table: "components/simple_table.html"
button_group_sm: "components/button_group_sm.html"
button_group_button_sm: "components/button_group_button_sm.html"

View File

@ -1,5 +0,0 @@
<div class="inline-flex rounded-md shadow-sm" role="group">
{% for button in buttons %}
{% button_group_button_sm href=button.href text=button.text color=button.color %}
{% endfor %}
</div>

View File

@ -1,13 +0,0 @@
<a href="{{ edit_url }}">
<button type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5">
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
</svg>
</button>
</a>

View File

@ -1,15 +0,0 @@
{% fragment as default_content %}
{% for td in data %}
{% if forloop.first %}
<th scope="row"
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
{% else %}
{% #table_td %}
{{ td }}
{% /table_td %}
{% endif %}
{% endfor %}
{% endfragment %}
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border-b">
{{ children|default:default_content }}
</tr>

View File

@ -1 +0,0 @@
<td class="px-6 py-4 min-w-20-char max-w-20-char">{{ children }}</td>

View File

@ -0,0 +1,5 @@
<button type="button"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 mt-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
{{ text }}
{{ slot }}
</button>

View File

@ -1,4 +1,4 @@
{% var color=color|default:"gray" %}
<c-vars color="gray" />
<a href="{{ href }}"
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
{% if color == "gray" %}

View File

@ -0,0 +1,6 @@
<c-vars color="gray" />
<div class="inline-flex rounded-md shadow-sm" role="group">
{% for button in buttons %}
<c-button-group-button-sm :href=button.href :text=button.text :color=button.color />
{% endfor %}
</div>

View File

@ -1,8 +1,8 @@
<span class="truncate-container">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game_id %}">
{% if children %}
{{ children }}
{% if slot %}
{{ slot }}
{% else %}
{{ name }}
{% endif %}

View File

@ -0,0 +1,8 @@
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
{{ slot }}
{% if badge %}
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2">
{{ badge }}
</span>
{% endif %}
</h1>

View File

@ -2,6 +2,6 @@
id="{{ id }}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
<div class="px-3 py-2">{{ children }}</div>
<div class="px-3 py-2">{{ slot }}</div>
<div data-popper-arrow></div>
</div>

View File

@ -1,26 +1,30 @@
{% load param_utils %}
<div class="shadow-md sm:rounded-lg" hx-boost="false">
<div class="relative overflow-x-auto">
<div class="relative overflow-x-auto sm:rounded-lg">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
{% if header_action %}
<c-table-header>
{{ header_action }}
</c-table-header>
{% endif %}
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% table_row data=row %}
{% endfor %}
{% for row in rows %}<c-table-row :data=row />{% endfor %}
</tbody>
</table>
</div>
{% if page_obj %}
{% if page_obj and elided_page_range %}
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4"
aria-label="Table navigation">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}"
<a href="?{% param_replace page=page_obj.previous_page_number %}"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Previous</a>
{% else %}
<a aria-current="page"
@ -29,7 +33,7 @@
{% for page in elided_page_range %}
<li>
{% if page != page_obj.number %}
<a href="?page={{ page }}"
<a href="?{% param_replace page=page %}"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">{{ page }}</a>
{% else %}
<a aria-current="page"
@ -38,7 +42,7 @@
</li>
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}"
<a href="?{% param_replace page=page_obj.next_page_number %}"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Next</a>
{% else %}
<a aria-current="page"

View File

@ -6,7 +6,7 @@
</tr>
</thead>
<tbody>
{{ children }}
{{ slot }}
</tbody>
</table>
</div>

View File

@ -0,0 +1,3 @@
<caption class="p-2 text-lg font-semibold rtl:text-left text-right text-gray-900 bg-white dark:text-white dark:bg-gray-900">
{{ slot }}
</caption>

View File

@ -0,0 +1,16 @@
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border-b [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2">
{% if slot %}
{{ slot }}
{% else %}
{% for td in data %}
{% if forloop.first %}
<th scope="row"
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
{% else %}
<c-table-td>
{{ td }}
</c-table-td>
{% endif %}
{% endfor %}
{% endif %}
</tr>

View File

@ -0,0 +1 @@
<td class="px-6 py-4 min-w-20-char max-w-20-char">{{ slot }}</td>

View File

@ -4,7 +4,7 @@
{{ title }}
{% endblock title %}
{% block content %}
<div class="2xl:max-w-screen-2xl md:max-w-screen-md sm:max-w-screen-sm self-center">
{% simple_table columns=data.columns rows=data.rows page_obj=page_obj elided_page_range=elided_page_range %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range />
</div>
{% endblock content %}

View File

@ -5,11 +5,11 @@
{% load static %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
{% #gamelink game_id=purchase.edition.game.id %}
<c-gamelink :game_id=purchase.edition.game.id>
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
{% /gamelink %}
</c-gamelink>
{% else %}
{% gamelink game_id=purchase.edition.game.id name=purchase.edition.name %}
<c-gamelink :game_id=purchase.edition.game.id name=purchase.edition.name />
{% endif %}
{% endpartialdef %}
{% block content %}
@ -65,31 +65,31 @@
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ longest_session_time }} ({% gamelink game_id=longest_session_game.id name=longest_session_game.name %})
{{ longest_session_time }} (<c-gamelink :game_id=longest_session_game.id :name=longest_session_game.name />)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ highest_session_count }} ({% gamelink game_id=highest_session_count_game.id name=highest_session_count_game.name %})
{{ highest_session_count }} (<c-gamelink :game_id=highest_session_count_game.id :name=highest_session_count_game.name />)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ highest_session_average }} ({% gamelink game_id=highest_session_average_game.id name=highest_session_average_game.name %})
{{ highest_session_average }} (<c-gamelink :game_id=highest_session_average_game.id :name=highest_session_average_game.name />)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{% gamelink game_id=first_play_game.id name=first_play_game.name %} ({{ first_play_date }})
<c-gamelink :game_id=first_play_game.id :name=first_play_game.name /> ({{ first_play_date }})
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{% gamelink game_id=last_play_game.id name=last_play_game.name %} ({{ last_play_date }})
<c-gamelink :game_id=last_play_game.id :name=last_play_game.name /> ({{ last_play_date }})
</td>
</tr>
</tbody>
@ -151,7 +151,9 @@
<tbody>
{% for game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% gamelink game_id=game.id name=game.name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=game.id :name=game.name />
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
{% endfor %}

View File

@ -10,9 +10,9 @@
<div class="flex gap-5 mb-3">
<span class="text-wrap max-w-80 text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>&nbsp;<span data-popover-target="popover-year" class="text-slate-500 text-2xl">{{ game.year_released }}</span>
{% #popover id="popover-year" %}
<c-popover id="popover-year">
Original release year
{% /popover %}
</c-popover>
</span>
</div>
<div class="flex gap-4 dark:text-slate-400 mb-3">
@ -26,9 +26,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{{ hours_sum }}
{% #popover id="popover-hours" %}
<c-popover id="popover-hours">
Total hours played
{% /popover %}
</c-popover>
</span>
<span data-popover-target="popover-sessions"
class="flex gap-2 items-center">
@ -41,9 +41,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
</svg>
{{ session_count }}
{% #popover id="popover-sessions" %}
<c-popover id="popover-sessions">
Number of sessions
{% /popover %}
</c-popover>
</span>
<span data-popover-target="popover-average" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
@ -55,9 +55,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
</svg>
{{ session_average_without_manual }}
{% #popover id="popover-average" %}
<c-popover id="popover-average">
Average playtime per session
{% /popover %}
</c-popover>
</span>
<span data-popover-target="popover-playrange"
class="flex gap-2 items-center">
@ -70,9 +70,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
</svg>
{{ playrange }}
{% #popover id="popover-playrange" %}
<c-popover id="popover-playrange">
Earliest and latest dates played
{% /popover %}
</c-popover>
</span>
</div>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
@ -90,102 +90,22 @@
</a>
</div>
</div>
<h1 class="text-3xl mt-4 mb-1 font-condensed">
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1>
<ul>
{% for edition in editions %}
<li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %}
<span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6"
width="48"
height="48"
alt="Wikidata Icon"
src="{% static 'icons/wikidata.png' %}" />
</a>
</span>
{% endif %}
{% url 'edit_edition' edition.id as edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %}
</li>
<ul>
{% for purchase in edition.game_purchases %}
<li class="sm:pl-6 flex items-center {% if purchase.date_refunded %}text-red-600{% endif %}">
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
{% url 'edit_purchase' purchase.id as edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %}
</li>
<ul>
{% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-12 flex items-center">
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
{% endfor %}
</ul>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center font-condensed">
Sessions
<span class="dark:text-slate-500" id="session-count">({{ session_count }})</span>
{% if latest_session_id %}
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %}
<a class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
title="Start new session"
href="{{ add_session_link }}"
hx-get="{{ add_session_link }}"
hx-vals="js:{session_count:getSessionCount()}"
hx-target="#session-list"
hx-swap="afterbegin">New</a>
{% endif %}
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1>
<ul id="session-list">
{% for session in sessions %}
{% partialdef session-info inline=True %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1">
{{ session.timestamp_start | date:"d/m/Y H:i" }}
{% if session.timestamp_end %}-{{ session.timestamp_end | date:"H:i" }}{% endif %}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %}
{% if not session.timestamp_end %}
{% url 'view_game_end_session' session.id as end_session_url %}
<a class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center"
href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest li"
hx-swap="outerHTML"
hx-vals="js:{session_count:getSessionCount()}"
hx-indicator="#indicator">
<svg xmlns="http://www.w3.org/2000/svg"
fill="#ffffff"
class="h-3"
x="0px"
y="0px"
viewBox="0 0 24 24">
<path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z">
</path>
</svg>
</a>
{% endif %}
</li>
<li class="sm:pl-4 markdown-content">{{ session.note|markdown }}</li>
<div class="hidden" hx-swap-oob="innerHTML:#session-count">({{ session_count }})</div>
{% endpartialdef %}
{% endfor %}
</ul>
</div>
<script>
<c-h1 :badge=edition_count>Editions</c-h1>
<div class="mb-6">
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
</div>
<div class="mb-6">
<c-h1 :badge=purchase_count>Purchases</c-h1>
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />
</div>
<div class="mb-6">
<c-h1 :badge=session_count>Sessions</c-h1>
<c-simple-table :rows=session_data.rows :columns=session_data.columns :page_obj=session_page_obj :elided_page_range=session_elided_page_range />
</div>
</div>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
</script>
{% endblock content %}

View File

@ -0,0 +1,18 @@
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()

View File

@ -1,142 +1,110 @@
from django.urls import path
from games import (
deviceviews,
editionviews,
gameviews,
platformviews,
purchaseviews,
sessionviews,
views,
)
from games.views import device, edition, game, general, platform, purchase, session
urlpatterns = [
path("", views.index, name="index"),
path("device/add", views.add_device, name="add_device"),
path(
"device/delete/<int:device_id>", deviceviews.delete_device, name="delete_device"
),
path("device/edit/<int:device_id>", deviceviews.edit_device, name="edit_device"),
path("device/list", deviceviews.list_devices, name="list_devices"),
path("edition/add", views.add_edition, name="add_edition"),
path("", general.index, name="index"),
path("device/add", device.add_device, name="add_device"),
path("device/delete/<int:device_id>", device.delete_device, name="delete_device"),
path("device/edit/<int:device_id>", device.edit_device, name="edit_device"),
path("device/list", device.list_devices, name="list_devices"),
path("edition/add", edition.add_edition, name="add_edition"),
path(
"edition/add/for-game/<int:game_id>",
views.add_edition,
edition.add_edition,
name="add_edition_for_game",
),
path("edition/<int:edition_id>/edit", views.edit_edition, name="edit_edition"),
path("edition/list", editionviews.list_editions, name="list_editions"),
path("edition/<int:edition_id>/edit", edition.edit_edition, name="edit_edition"),
path("edition/list", edition.list_editions, name="list_editions"),
path(
"edition/<int:edition_id>/delete",
editionviews.delete_edition,
edition.delete_edition,
name="delete_edition",
),
path("game/add", views.add_game, name="add_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/delete", views.delete_game, name="delete_game"),
path("game/list", gameviews.list_games, name="list_games"),
path("platform/add", views.add_platform, name="add_platform"),
path("platform/<int:platform_id>/edit", views.edit_platform, name="edit_platform"),
path("game/add", game.add_game, name="add_game"),
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
path("game/<int:game_id>/view", game.view_game, name="view_game"),
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
path("game/list", game.list_games, name="list_games"),
path("platform/add", platform.add_platform, name="add_platform"),
path(
"platform/<int:platform_id>/edit",
platform.edit_platform,
name="edit_platform",
),
path(
"platform/<int:platform_id>/delete",
platformviews.delete_platform,
platform.delete_platform,
name="delete_platform",
),
path("platform/list", platformviews.list_platforms, name="list_platforms"),
path("purchase/add", views.add_purchase, name="add_purchase"),
path("purchase/<int:purchase_id>/edit", views.edit_purchase, name="edit_purchase"),
path("platform/list", platform.list_platforms, name="list_platforms"),
path("purchase/add", purchase.add_purchase, name="add_purchase"),
path(
"purchase/<int:purchase_id>/edit",
purchase.edit_purchase,
name="edit_purchase",
),
path(
"purchase/<int:purchase_id>/delete",
views.delete_purchase,
purchase.delete_purchase,
name="delete_purchase",
),
path(
"purchase/list",
purchaseviews.list_purchases,
purchase.list_purchases,
name="list_purchases",
),
path(
"purchase/related-purchase-by-edition",
views.related_purchase_by_edition,
purchase.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path(
"purchase/add/for-edition/<int:edition_id>",
views.add_purchase,
purchase.add_purchase,
name="add_purchase_for_edition",
),
path("session/add", views.add_session, name="add_session"),
path("session/add", session.add_session, name="add_session"),
path(
"session/add/for-purchase/<int:purchase_id>",
views.add_session,
session.add_session,
name="add_session_for_purchase",
),
path(
"session/add/from-game/<int:session_id>",
views.new_session_from_existing_session,
session.new_session_from_existing_session,
{"template": "view_game.html#session-info"},
name="view_game_start_session_from_session",
),
path(
"session/add/from-list/<int:session_id>",
views.new_session_from_existing_session,
session.new_session_from_existing_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_start_session_from_session",
),
path("session/<int:session_id>/edit", views.edit_session, name="edit_session"),
path("session/<int:session_id>/edit", session.edit_session, name="edit_session"),
path(
"session/<int:session_id>/delete",
views.delete_session,
session.delete_session,
name="delete_session",
),
path(
"session/end/from-game/<int:session_id>",
views.end_session,
session.end_session,
{"template": "view_game.html#session-info"},
name="view_game_end_session",
),
path(
"session/end/from-list/<int:session_id>",
views.end_session,
session.end_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_end_session",
),
path("session/list", sessionviews.list_sessions, name="list_sessions"),
path(
"session/list/by-purchase/<int:purchase_id>",
views.list_sessions,
{"filter": "purchase"},
name="list_sessions_by_purchase",
),
path(
"session/list/by-platform/<int:platform_id>",
views.list_sessions,
{"filter": "platform"},
name="list_sessions_by_platform",
),
path(
"session/list/by-game/<int:game_id>",
views.list_sessions,
{"filter": "game"},
name="list_sessions_by_game",
),
path(
"session/list/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "edition"},
name="list_sessions_by_edition",
),
path(
"session/list/by-ownership/<str:ownership_type>",
views.list_sessions,
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
path("stats/", views.stats_alltime, name="stats_alltime"),
path("session/list", session.list_sessions, name="list_sessions"),
path("stats/", general.stats_alltime, name="stats_alltime"),
path(
"stats/<int:year>",
views.stats,
general.stats,
name="stats_by_year",
),
]

0
games/views/__init__.py Normal file
View File

View File

@ -7,9 +7,10 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import A, Button
from games.forms import DeviceForm
from games.models import Device
from games.views import dateformat
from games.views.general import dateformat
@login_required
@ -35,6 +36,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add device"), url="add_device"),
"columns": [
"Name",
"Type",
@ -47,12 +49,13 @@ def list_devices(request: HttpRequest) -> HttpResponse:
device.get_type_display(),
device.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_device", args=[device.pk]),
"text": "Edit",
"color": "gray",
},
{
"href": reverse("delete_device", args=[device.pk]),
@ -87,3 +90,16 @@ def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
device = get_object_or_404(Device, id=device_id)
device.delete()
return redirect("list_sessions")
@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("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)

View File

@ -2,15 +2,15 @@ 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.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.urls import reverse
from common.utils import truncate_with_popover
from common.utils import A, Button, truncate_with_popover
from games.forms import EditionForm
from games.models import Edition
from games.views import dateformat
from games.models import Edition, Game
from games.views.general import dateformat
@login_required
@ -36,6 +36,7 @@ def list_editions(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add edition"), url="add_edition"),
"columns": [
"Game",
"Name",
@ -48,7 +49,18 @@ def list_editions(request: HttpRequest) -> HttpResponse:
],
"rows": [
[
truncate_with_popover(edition.game.name),
A(
[
(
"href",
reverse(
"view_game",
args=[edition.game.pk],
),
)
],
truncate_with_popover(edition.game.name),
),
truncate_with_popover(
edition.name
if edition.game.name != edition.name
@ -65,12 +77,13 @@ def list_editions(request: HttpRequest) -> HttpResponse:
edition.wikidata,
edition.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"text": "Edit",
"color": "gray",
},
{
"href": reverse(
@ -91,7 +104,7 @@ def list_editions(request: HttpRequest) -> HttpResponse:
@login_required
def edit_device(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
def edit_edition(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
@ -107,3 +120,38 @@ def delete_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
edition.delete()
return redirect("list_editions")
@login_required
def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = get_object_or_404(Game, id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context)

335
games/views/game.py Normal file
View File

@ -0,0 +1,335 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Prefetch
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.urls import reverse
from common.time import format_duration
from common.utils import A, Button, safe_division, truncate_with_popover
from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session
from games.views.general import (
dateformat,
datetimeformat,
durationformat,
durationformat_manual,
timeformat,
use_custom_redirect,
)
@login_required
def list_games(request: HttpRequest) -> 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")
page_obj = None
if int(limit) != 0:
paginator = Paginator(games, limit)
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
),
"data": {
"header_action": A([], Button([], "Add game"), url="add_game"),
"columns": [
"Name",
"Sort Name",
"Year",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
A(
[
(
"href",
reverse(
"view_game",
args=[game.pk],
),
)
],
truncate_with_popover(game.name),
),
truncate_with_popover(
game.sort_name
if game.sort_name is not None and game.name != game.sort_name
else "(identical)"
),
game.year_released,
game.wikidata,
game.created_at.strftime(dateformat),
render_to_string(
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_game", args=[game.pk]),
"text": "Edit",
"color": "gray",
},
{
"href": reverse("delete_game", args=[game.pk]),
"text": "Delete",
"color": "red",
},
]
},
),
]
for game in games
],
},
}
return render(request, "list_purchases.html", context)
@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()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("list_games")
context["form"] = form
context["title"] = "Add New Game"
context["script_name"] = "add_game.js"
return render(request, "add_game.html", context)
@login_required
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id)
game.delete()
return redirect("list_sessions")
@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("list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
@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(
"purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased")
sessions = Session.objects.prefetch_related("device").filter(
purchase__edition__game=game
)
session_count = sessions.count()
session_count_without_manual = (
Session.objects.without_manual().filter(purchase__edition__game=game).count()
)
if sessions:
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest()
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
else:
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")
)
edition_data: dict[str, Any] = {
"columns": [
"Name",
"Platform",
"Year Released",
"Actions",
],
"rows": [
[
edition.name,
edition.platform,
edition.year_released,
render_to_string(
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"text": "Edit",
"color": "gray",
},
{
"href": reverse("delete_edition", args=[edition.pk]),
"text": "Delete",
"color": "red",
},
]
},
),
]
for edition in editions
],
}
purchase_data: dict[str, Any] = {
"columns": ["Name", "Type", "Price", "Actions"],
"rows": [
[
purchase.name if purchase.name else purchase.edition.name,
purchase.get_type_display(),
f"{purchase.price} {purchase.price_currency}",
render_to_string(
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_purchase", args=[purchase.pk]),
"text": "Edit",
"color": "gray",
},
{
"href": reverse("delete_purchase", args=[purchase.pk]),
"text": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
}
sessions_all = Session.objects.filter(purchase__edition__game=game).order_by(
"-timestamp_start"
)
session_count = sessions_all.count()
session_paginator = Paginator(sessions_all, 5)
page_number = request.GET.get("page", 1)
session_page_obj = session_paginator.get_page(page_number)
sessions = session_page_obj.object_list
session_data: dict[str, Any] = {
"columns": ["Date", "Duration", "Duration (manual)", "Actions"],
"rows": [
[
f"{session.timestamp_start.strftime(datetimeformat)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
else "-"
),
(
format_duration(session.duration_manual, durationformat_manual)
if session.duration_manual
else "-"
),
render_to_string(
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_session", args=[session.pk]),
"text": "Edit",
"color": "gray",
},
{
"href": reverse("delete_session", args=[session.pk]),
"text": "Delete",
"color": "red",
},
]
},
),
]
for session in sessions
],
}
context: dict[str, Any] = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average_without_manual": round(
safe_division(
total_hours_without_manual, int(session_count_without_manual)
),
1,
),
"session_count": session_count,
"sessions": sessions,
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"edition_data": edition_data,
"purchase_data": purchase_data,
"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
),
}
request.session["return_path"] = request.path
return render(request, "view_game.html", context)

View File

@ -1,31 +1,16 @@
from typing import Any, Callable
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields
from django.db.models.functions import TruncDate, TruncMonth
from django.db.models.manager import BaseManager
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
from common.time import format_duration
from common.utils import safe_division, safe_getattr
from .forms import (
DeviceForm,
EditionForm,
GameForm,
PlatformForm,
PurchaseForm,
SessionForm,
)
from .models import Edition, Game, Platform, Purchase, Session
from common.utils import safe_division
from games.models import Edition, Game, Platform, Purchase, Session
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
@ -44,42 +29,6 @@ def model_counts(request: HttpRequest) -> dict[str, bool]:
}
def stats_dropdown_year_range(request: HttpRequest) -> dict[str, range]:
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
return result
@login_required
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last()
if last != None:
initial["purchase"] = last.purchase
if request.method == "POST":
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
}
)
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session"
context["form"] = form
return render(request, "add_session.html", context)
def use_custom_redirect(
func: Callable[..., HttpResponse],
) -> Callable[..., HttpResponse]:
@ -98,265 +47,6 @@ def use_custom_redirect(
return wrapper
@login_required
@use_custom_redirect
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("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add_session.html", context)
@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("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)
@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("list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
@login_required
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id)
game.delete()
return redirect("list_sessions")
@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(
"purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
sessions = Session.objects.prefetch_related("device").filter(
purchase__edition__game=game
)
session_count = sessions.count()
session_count_without_manual = (
Session.objects.without_manual().filter(purchase__edition__game=game).count()
)
if sessions:
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest()
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
else:
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")
)
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average_without_manual": round(
safe_division(
total_hours_without_manual, int(session_count_without_manual)
),
1,
),
"session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"latest_session_id": safe_getattr(latest_session, "pk"),
}
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@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("list_platforms")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
@login_required
@use_custom_redirect
def edit_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
context = {}
edition = get_object_or_404(Edition, id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Edition"
context["form"] = form
return render(request, "add.html", context)
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
def clone_session_by_id(session_id: int) -> Session:
session = get_object_or_404(Session, id=session_id)
clone = session
clone.pk = None
clone.timestamp_start = timezone.now()
clone.timestamp_end = None
clone.note = ""
clone.save()
return clone
@login_required
@use_custom_redirect
def new_session_from_existing_session(
request: HttpRequest, session_id: int, template: str = ""
) -> 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 redirect("list_sessions")
@login_required
@use_custom_redirect
def end_session(
request: HttpRequest, session_id: int, template: str = ""
) -> 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("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 redirect("list_sessions")
@login_required
def list_sessions(
request: HttpRequest,
filter: str = "",
purchase_id: int = 0,
platform_id: int = 0,
game_id: int = 0,
edition_id: int = 0,
ownership_type: str = "",
) -> HttpResponse:
context = {}
context["title"] = "Sessions"
all_sessions = Session.objects.prefetch_related(
"purchase", "purchase__edition", "purchase__edition__game"
).order_by("-timestamp_start")
if filter == "purchase":
dataset = all_sessions.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform":
dataset = all_sessions.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition":
dataset = all_sessions.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game":
dataset = all_sessions.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type":
dataset = all_sessions.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
context["title"] = "This year"
else:
dataset = all_sessions
context = {
**context,
"dataset": dataset,
"dataset_count": dataset.count(),
"last": Session.objects.prefetch_related("purchase__platform").latest(),
}
return render(request, "list_sessions.html", context)
@login_required
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
@ -815,129 +505,6 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return render(request, "stats.html", context)
@login_required
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.delete()
return redirect("list_sessions")
@login_required
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
purchase = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
)
)
else:
return redirect("index")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
}
)
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)
@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()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("index")
context["form"] = form
context["title"] = "Add New Game"
context["script_name"] = "add_game.js"
return render(request, "add_game.html", context)
@login_required
def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = get_object_or_404(Game, id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context)
@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("index")
context["form"] = form
context["title"] = "Add New Platform"
return render(request, "add.html", context)
@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("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
@login_required
def index(request: HttpRequest) -> HttpResponse:
return redirect("list_sessions")

View File

@ -7,8 +7,10 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import A, Button
from games.forms import PlatformForm
from games.models import Platform
from games.views import dateformat
from games.views.general import dateformat, use_custom_redirect
@login_required
@ -34,6 +36,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
else None
),
"data": {
"header_action": A([], Button([], "Add platform"), url="add_platform"),
"columns": [
"Name",
"Group",
@ -46,7 +49,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
platform.group,
platform.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
"cotton/button_group_sm.html",
{
"buttons": [
{
@ -54,6 +57,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
"edit_platform", args=[platform.pk]
),
"text": "Edit",
"color": "gray",
},
{
"href": reverse(
@ -78,3 +82,30 @@ def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
platform = get_object_or_404(Platform, id=platform_id)
platform.delete()
return redirect("list_platforms")
@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("list_platforms")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
@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("index")
context["form"] = form
context["title"] = "Add New Platform"
return render(request, "add.html", context)

196
games/views/purchase.py Normal file
View File

@ -0,0 +1,196 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from common.utils import A, Button, truncate_with_popover
from games.forms import PurchaseForm
from games.models import Edition, Purchase
from games.views.general import dateformat, use_custom_redirect
@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")
page_obj = None
if int(limit) != 0:
paginator = Paginator(purchases, limit)
page_obj = paginator.get_page(page_number)
purchases = page_obj.object_list
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="add_purchase"),
"columns": [
"Name",
"Type",
"Platform",
"Price",
"Currency",
"Infinite",
"Purchased",
"Refunded",
"Finished",
"Dropped",
"Created",
"Actions",
],
"rows": [
[
A(
[
(
"href",
reverse(
"view_game",
args=[purchase.edition.game.pk],
),
),
],
truncate_with_popover(
purchase.edition.game.name
if purchase.type == "game"
else f"{purchase.edition.game.name} ({purchase.name})"
),
),
purchase.get_type_display(),
purchase.platform,
purchase.price,
purchase.price_currency,
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat),
render_to_string(
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse(
"edit_purchase", args=[purchase.pk]
),
"text": "Edit",
"color": "gray",
},
{
"href": reverse(
"delete_purchase", args=[purchase.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
},
}
return render(request, "list_purchases.html", context)
@login_required
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
purchase = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
)
)
else:
return redirect("list_purchases")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
}
)
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)
@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("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)
@login_required
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.delete()
return redirect("list_sessions")
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})

477
games/views/session.py Normal file
View File

@ -0,0 +1,477 @@
import operator
from functools import reduce
from json import dumps as json_dumps
from json import loads as json_loads
from typing import Any, NotRequired, TypeAlias, TypedDict, TypeGuard
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models.query import QuerySet
from django.db.models.query_utils 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.urls import reverse
from django.utils import timezone
from typing_extensions import TypeGuard
from common.time import format_duration
from common.utils import A, Button, truncate_with_popover
from games.forms import SessionForm
from games.models import Purchase, Session
from games.views.general import (
dateformat,
datetimeformat,
durationformat,
durationformat_manual,
timeformat,
use_custom_redirect,
)
class Filter(TypedDict):
filter_id: str
filter_display: str
filter_string: str
def is_filter(obj: dict[Any, Any]) -> TypeGuard[Filter]:
return (
isinstance(obj, dict)
and "filter_id" in obj
and isinstance(obj["filter_id"], str)
and "filter_display" in obj
and isinstance(obj["filter_display"], str)
and "filter_string" in obj
and isinstance(obj["filter_string"], str)
)
FilterList: TypeAlias = list[Filter]
def is_filterlist(obj: list[Any]) -> TypeGuard[FilterList]:
return isinstance(obj, list) and all([is_filter(item) for item in obj])
ModelFilterSet: TypeAlias = list[dict[str, FilterList]]
class FieldFilter(TypedDict):
filtered_field: str
filtered_value: str
negated: NotRequired[bool]
filter: Filter
def is_fieldfilter(obj: dict) -> TypeGuard[FieldFilter]:
return (
isinstance(obj, dict)
and "filtered_field" in obj
and isinstance(obj["filtered_field"], str)
and "filtered_value" in obj
and isinstance(obj["filtered_value"], str)
and "filter" in obj
and is_filter(obj["filter"])
)
FilterSet: TypeAlias = list[FieldFilter]
def is_filterset(obj: list) -> TypeGuard[FilterSet]:
return isinstance(obj, list) and all([is_fieldfilter(item) for item in obj])
iexact_filter: Filter = {
"filter_id": "IEXACT",
"filter_display": "Equals (case-insensitive)",
"filter_string": "__iexact",
}
exact_filter: Filter = {
"filter_id": "EXACT",
"filter_display": "Equals (case-sensitive)",
"filter_string": "__exact",
}
isnull_filter: Filter = {
"filter_id": "ISNULL",
"filter_display": "Is null",
"filter_string": "__isnull",
}
contains_filter: Filter = {
"filter_id": "CONTAINS",
"filter_display": "Contains",
"filter_string": "__contains",
}
startswith_filter: Filter = {
"filter_id": "STARTSWITH",
"filter_display": "Starts with",
"filter_string": "__startswith",
}
endswith_filter: Filter = {
"filter_id": "ENDSWITH",
"filter_display": "Ends with",
"filter_string": "__endswith",
}
gt_filter: Filter = {
"filter_id": "GT",
"filter_display": "Greater than",
"filter_string": "__gt",
}
lt_filter: Filter = {
"filter_id": "LT",
"filter_display": "Lesser than",
"filter_string": "__lt",
}
year_gt_filter: Filter = {
"filter_id": "YEARGT",
"filter_display": "Greater than",
"filter_string": "__year__gt",
}
year_lt_filter: Filter = {
"filter_id": "YEARLT",
"filter_display": "Lesser than",
"filter_string": "__year__lt",
}
year_exact_filter: Filter = {
"filter_id": "YEAREXACT",
"filter_display": "Equals (case-sensitive)",
"filter_string": "__year__exact",
}
defined_filters = [
iexact_filter,
exact_filter,
isnull_filter,
contains_filter,
startswith_filter,
endswith_filter,
gt_filter,
lt_filter,
year_gt_filter,
year_lt_filter,
year_exact_filter,
]
defined_filters_list = {list["filter_id"]: list for list in defined_filters}
char_filter: FilterList = [
iexact_filter,
isnull_filter,
contains_filter,
startswith_filter,
endswith_filter,
]
text_filter: FilterList = [
isnull_filter,
contains_filter,
]
num_filter: FilterList = [exact_filter, gt_filter, lt_filter]
date_filter: FilterList = [
year_exact_filter,
isnull_filter,
year_gt_filter,
year_lt_filter,
]
conditions = ["and", "or"]
session_filters: ModelFilterSet = [
{"name": char_filter},
{"timestamp_start": date_filter},
{"timestamp_end": date_filter},
{"duration_manual": num_filter},
{"duration_calculated": num_filter},
{"note": text_filter},
{"device": char_filter},
{"created_at": date_filter},
{"modified_at": date_filter},
]
name_contains_age: FieldFilter = {
"filtered_field": "name",
"filtered_value": "age",
"filter": contains_filter,
}
simple_example_filter: FilterSet = [name_contains_age]
timestamp_start_year_2024: FieldFilter = {
"filtered_field": "timestamp_start",
"filtered_value": "2024",
"filter": year_exact_filter,
}
physical_only: FieldFilter = {
"filtered_field": "purchase__ownership_type",
"filtered_value": "ph",
"filter": exact_filter,
}
def negate_filter(filter: FieldFilter) -> FieldFilter:
return {**filter, "negated": True}
without_physical: FieldFilter = negate_filter(physical_only)
combined_example_filter: FilterSet = [name_contains_age, timestamp_start_year_2024]
combined_with_negated_example_filter = [timestamp_start_year_2024, without_physical]
def string_to_dict(s: str) -> dict[str, str]:
key, value = s.split("=")
return {key: value}
def create_django_filter_dict(
filter: Filter, field: str, filtered_value: str
) -> dict[str, str]:
"""
Creates a dict that can be used with the Django
filter function by unpacking it:
Model.objects.filter(**return_value)
"""
if not is_filter(filter):
raise ValueError("filter is not of type Filter")
return {f"{field}{filter["filter_string"]}": filtered_value}
def join_filter_with_condition(filters: FilterSet, condition: str):
if not is_filterset(filters):
raise ValueError("filters is not FilterSet")
conditions = {"AND": operator.and_, "OR": operator.or_, "XOR": operator.xor}
condition = condition.upper()
if condition not in conditions:
raise ValueError(f"Condition '{condition}' not one of '{conditions.keys()}'.")
q_objects: list[Q] = []
for filter_item in filters:
q = Q(
**create_django_filter_dict(
filter_item["filter"],
filter_item["filtered_field"],
filter_item["filtered_value"],
)
)
if filter_item.get("negated", False):
q = ~q
q_objects.append(q)
return reduce(conditions[condition], q_objects)
def apply_filters(
filters: FilterSet,
queryset: QuerySet[Any],
) -> QuerySet[Any] | None:
if len(filters) == 0:
return queryset
if type(filters) is not list:
raise ValueError("filters argument not of type list")
# TODO: modify FilterSet so it includes the condition to use
# so we can remove the hard-coding of "AND" here
return queryset.filter(join_filter_with_condition(filters, "AND"))
def filters_to_string(filters: FilterSet) -> str:
constructed_filters: list[dict[str, str | bool]] = []
for filter in filters:
constructed_filters.append(
{
"id": filter["filter"]["filter_id"],
"field": filter["filtered_field"],
"value": filter["filtered_value"],
"negated": filter.get("negated", False),
}
)
return json_dumps(constructed_filters)
def string_to_filters(filter_string: str) -> FilterSet:
obj = json_loads(filter_string)
filters = [
{
"filter": defined_filters_list[item["id"]],
"filtered_field": item["field"],
"filtered_value": item["value"],
"negated": item.get("negated", False),
}
for item in obj
]
if not is_filterset(filters):
raise ValueError("filters is not of type FilterSet")
return filters
@login_required
def list_sessions(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
filters = request.GET.get("filters", "")
sessions = Session.objects.order_by("-timestamp_start")
if filters != "":
filter_obj = string_to_filters(filters)
sessions = apply_filters(filter_obj, queryset=sessions)
page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
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": A([], Button([], "Add session"), url="add_session"),
"columns": [
"Name",
"Date",
"Duration",
"Duration (manual)",
"Device",
"Created",
"Actions",
],
"rows": [
[
A(
children=truncate_with_popover(session.purchase.edition.name),
url=reverse(
"view_game",
args=[session.purchase.edition.game.pk],
),
),
f"{session.timestamp_start.strftime(datetimeformat)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
else "-"
),
(
format_duration(session.duration_manual, durationformat_manual)
if session.duration_manual
else "-"
),
session.device,
session.created_at.strftime(dateformat),
render_to_string(
"cotton/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_session", args=[session.pk]),
"text": "Edit",
"color": "gray",
},
{
"href": reverse(
"delete_session", args=[session.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for session in sessions
],
},
}
return render(request, "list_purchases.html", context)
@login_required
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last()
if last != None:
initial["purchase"] = last.purchase
if request.method == "POST":
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
}
)
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session"
context["form"] = form
return render(request, "add_session.html", context)
@login_required
@use_custom_redirect
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("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add_session.html", context)
def clone_session_by_id(session_id: int) -> Session:
session = get_object_or_404(Session, id=session_id)
clone = session
clone.pk = None
clone.timestamp_start = timezone.now()
clone.timestamp_end = None
clone.note = ""
clone.save()
return clone
@login_required
@use_custom_redirect
def new_session_from_existing_session(
request: HttpRequest, session_id: int, template: str = ""
) -> 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 redirect("list_sessions")
@login_required
@use_custom_redirect
def end_session(
request: HttpRequest, session_id: int, template: str = ""
) -> 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("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 redirect("list_sessions")

70
poetry.lock generated
View File

@ -28,6 +28,27 @@ files = [
[package.extras]
tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
[[package]]
name = "beautifulsoup4"
version = "4.12.3"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.6.0"
files = [
{file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
{file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
]
[package.dependencies]
soupsieve = ">1.2"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "cfgv"
version = "3.4.0"
@ -110,6 +131,20 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "django-cotton"
version = "0.9.34"
description = "Bringing component based design to Django templates."
optional = false
python-versions = "<4,>=3.8"
files = [
{file = "django_cotton-0.9.34-py3-none-any.whl", hash = "sha256:9721dd79066d5a28eefb84527bea7f2daa8f1db6111ffdecbc5dd64fe2e300c9"},
{file = "django_cotton-0.9.34.tar.gz", hash = "sha256:3f2d950a9ad0985955ca0fb2d5fbb42be1f07f55239864fe5a1d0e873303f0bd"},
]
[package.dependencies]
beautifulsoup4 = ">=4.12.2,<4.13.0"
[[package]]
name = "django-debug-toolbar"
version = "4.4.6"
@ -788,22 +823,16 @@ files = [
]
[[package]]
name = "slippers"
version = "0.6.2"
description = "Build reusable components in Django without writing a single line of Python."
name = "soupsieve"
version = "2.6"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.8.0"
python-versions = ">=3.8"
files = [
{file = "slippers-0.6.2-py3-none-any.whl", hash = "sha256:739e05f85354becbf0a65daab831eea62557d89e7512042209ab629af4378bca"},
{file = "slippers-0.6.2.tar.gz", hash = "sha256:4cb555b8822ba0d404e5405723f5d723994022c29046008ee917081031bc0cf1"},
{file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"},
{file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"},
]
[package.dependencies]
Django = ">=3.2"
PyYAML = ">=5.4.0"
typeguard = ">=2.13.3,<3.0.0"
typing-extensions = ">=4.4.0"
[[package]]
name = "sqlparse"
version = "0.5.1"
@ -850,21 +879,6 @@ notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
[[package]]
name = "typeguard"
version = "2.13.3"
description = "Run-time type checker for Python"
optional = false
python-versions = ">=3.5.3"
files = [
{file = "typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1"},
{file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"},
]
[package.extras]
doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["mypy", "pytest", "typing-extensions"]
[[package]]
name = "typing-extensions"
version = "4.12.2"
@ -928,4 +942,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "eb741020e400664070ed1eee24a17b6a9c07a2f2d44103e4947279aa26a40315"
content-hash = "f96f55c381f25a4a473be8d53ef83d13c70bb7c731b5132f4ece2af1b3d3afed"

View File

@ -30,7 +30,7 @@ django-template-partials = "^24.2"
markdown = "^3.6"
slippers = "^0.6.2"
django-cotton = "^0.9.34"
[tool.isort]
profile = "black"

View File

@ -41,7 +41,7 @@ INSTALLED_APPS = [
"template_partials",
"graphene_django",
"django_htmx",
"slippers",
"django_cotton",
]
GRAPHENE = {"SCHEMA": "games.schema.schema"}
@ -83,12 +83,10 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"games.views.model_counts",
"games.views.stats_dropdown_year_range",
"games.views.general.model_counts",
],
"builtins": [
"template_partials.templatetags.partials",
"slippers.templatetags.slippers",
],
},
},