Compare commits

...

10 Commits

Author SHA1 Message Date
Lukáš Kucharczyk 1c28950b53
add pagination
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-08 22:54:15 +02:00
Lukáš Kucharczyk b54bcdd9e9
remove cruft 2024-08-08 21:20:17 +02:00
Lukáš Kucharczyk 9ec6c958c8
remove unnecessary styles 2024-08-08 21:20:08 +02:00
Lukáš Kucharczyk 25deac6ea9
add more types 2024-08-08 21:19:43 +02:00
Lukáš Kucharczyk a5ac10b20d
use model variables for foreign keys where possible 2024-08-08 20:22:25 +02:00
Lukáš Kucharczyk 3de40ccad3
create purchase list without paging 2024-08-08 20:17:43 +02:00
Lukáš Kucharczyk 6a5dc9b62c
even more formatting 2024-08-08 15:08:50 +02:00
Lukáš Kucharczyk b6014a72e0
.gitignore: add .direnv 2024-08-08 14:49:09 +02:00
Lukáš Kucharczyk 245b47b8b3
improve shell.nix
do not let poetry manage venvs
no need to override python3
2024-08-08 14:48:58 +02:00
Lukáš Kucharczyk e33f23c18f
add .envrc 2024-08-08 14:48:20 +02:00
25 changed files with 484 additions and 111 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use nix

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ db.sqlite3
dist/
.DS_Store
.python-version
.direnv

View File

@ -6,6 +6,7 @@
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
* Add all-time stats
* Manage purchases
## Improved
* mark refunded purchases red on game overview

View File

@ -120,14 +120,6 @@ textarea:disabled {
@apply mx-1;
}
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}

View File

@ -12,7 +12,7 @@ def _safe_timedelta(duration: timedelta | int | None):
def format_duration(
duration: timedelta | int | None, format_string: str = "%H hours"
duration: timedelta | int | float | None, format_string: str = "%H hours"
) -> str:
"""
Format timedelta into the specified format_string.

View File

@ -1,3 +1,6 @@
from typing import Any
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
@ -9,7 +12,7 @@ def safe_division(numerator: int | float, denominator: int | float) -> int | flo
return 0
def safe_getattr(obj, attr_chain, default=None):
def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object:
"""
Safely get the nested attribute from an object.

View File

@ -1,7 +1,7 @@
from django import forms
from django.urls import reverse
from common.utils import safe_getattr
from common.utils import safe_getattr
from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})

View File

@ -2,7 +2,7 @@ from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Manager, Sum
from django.db.models import F, Sum
from django.utils import timezone
from common.time import format_duration
@ -15,6 +15,18 @@ class Game(models.Model):
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
@ -23,11 +35,11 @@ class Edition(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE)
game = models.ForeignKey(Game, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
Platform, on_delete=models.CASCADE, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
@ -83,9 +95,9 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
@ -100,7 +112,7 @@ class Purchase(models.Model):
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"Purchase",
"self",
on_delete=models.SET_NULL,
default=None,
null=True,
@ -135,15 +147,6 @@ class Purchase(models.Model):
super().save(*args, **kwargs)
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class SessionQuerySet(models.QuerySet):
def total_duration_formatted(self):
return format_duration(self.total_duration_unformatted())
@ -172,7 +175,7 @@ class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
@ -220,7 +223,7 @@ class Session(models.Model):
def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs):
def save(self, *args, **kwargs) -> None:
if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start
else:

91
games/purchaseviews.py Normal file
View File

@ -0,0 +1,91 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models.manager import BaseManager
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 games.models import Purchase
from games.views import dateformat
@login_required
def list_purchases(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
paginator = Paginator(Purchase.objects.order_by("created_at"), 10)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
purchases = page_obj.object_list
context = {
"title": "Manage purchases",
"page_obj": page_obj,
"elided_page_range": page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
),
"data": {
"columns": [
"Name",
"Platform",
"Price",
"Currency",
"Infinite",
"Purchased",
"Refunded",
"Finished",
"Dropped",
"Created",
"Actions",
],
"rows": [
[
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

@ -1370,6 +1370,10 @@ input:checked + .toggle-bg {
margin-bottom: 0.75rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-8 {
margin-bottom: 2rem;
}
@ -1382,6 +1386,10 @@ input:checked + .toggle-bg {
margin-right: 1rem;
}
.ms-0 {
margin-inline-start: 0px;
}
.mt-2 {
margin-top: 0.5rem;
}
@ -1451,6 +1459,10 @@ input:checked + .toggle-bg {
height: 1.5rem;
}
.h-8 {
height: 2rem;
}
.h-9 {
height: 2.25rem;
}
@ -1639,6 +1651,12 @@ input:checked + .toggle-bg {
gap: 1.25rem;
}
.-space-x-px > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(-1px * var(--tw-space-x-reverse));
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));
@ -1659,6 +1677,10 @@ input:checked + .toggle-bg {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
@ -1717,6 +1739,10 @@ input:checked + .toggle-bg {
border-width: 0px;
}
.border-b {
border-bottom-width: 1px;
}
.border-blue-600 {
--tw-border-opacity: 1;
border-color: rgb(28 100 242 / var(--tw-border-opacity));
@ -1762,6 +1788,11 @@ input:checked + .toggle-bg {
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-gray-50 {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.bg-gray-800 {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
@ -1822,6 +1853,11 @@ input:checked + .toggle-bg {
padding-right: 1.25rem;
}
.px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
@ -1842,6 +1878,11 @@ input:checked + .toggle-bg {
padding-bottom: 0.75rem;
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.pb-16 {
padding-bottom: 4rem;
}
@ -1862,10 +1903,18 @@ input:checked + .toggle-bg {
padding-top: 0.5rem;
}
.pt-4 {
padding-top: 1rem;
}
.pt-8 {
padding-top: 2rem;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
@ -1939,10 +1988,18 @@ input:checked + .toggle-bg {
font-weight: 500;
}
.font-normal {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.leading-6 {
line-height: 1.5rem;
}
@ -1951,6 +2008,10 @@ input:checked + .toggle-bg {
line-height: 2.25rem;
}
.leading-tight {
line-height: 1.25;
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(28 100 242 / var(--tw-text-opacity));
@ -2256,14 +2317,6 @@ textarea:disabled:is(.dark *) {
margin-right: 0.25rem;
}
th {
text-align: right;
}
th label {
margin-right: 1rem;
}
.basic-button-container {
display: flex;
justify-content: center;
@ -2366,11 +2419,26 @@ th label {
}
} */
.odd\:bg-white:nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.even\:bg-gray-50:nth-child(even) {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.hover\:border-gray-300:hover {
--tw-border-opacity: 1;
border-color: rgb(209 213 219 / var(--tw-border-opacity));
}
.hover\:border-green-600:hover {
--tw-border-opacity: 1;
border-color: rgb(5 122 85 / var(--tw-border-opacity));
}
.hover\:bg-blue-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(30 66 159 / var(--tw-bg-opacity));
@ -2386,6 +2454,16 @@ th label {
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-gray-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.hover\:bg-green-500:hover {
--tw-bg-opacity: 1;
background-color: rgb(14 159 110 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(4 108 78 / var(--tw-bg-opacity));
@ -2396,6 +2474,11 @@ th label {
background-color: rgb(253 232 232 / var(--tw-bg-opacity));
}
.hover\:bg-red-500:hover {
--tw-bg-opacity: 1;
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));
@ -2421,11 +2504,21 @@ th label {
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.hover\:text-white:hover {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@ -2476,6 +2569,11 @@ th label {
--tw-ring-color: rgb(14 159 110 / var(--tw-ring-opacity));
}
.focus\:ring-green-700:focus {
--tw-ring-opacity: 1;
--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));
@ -2654,6 +2752,26 @@ th label {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.odd\:dark\:bg-gray-900:is(.dark *):nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.even\:dark\:bg-gray-800:is(.dark *):nth-child(even) {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
.dark\:hover\:border-green-700:hover:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(4 108 78 / var(--tw-border-opacity));
}
.dark\:hover\:border-red-700:hover:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(200 30 30 / var(--tw-border-opacity));
}
.dark\:hover\:bg-blue-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(26 86 219 / var(--tw-bg-opacity));
@ -2674,6 +2792,11 @@ th label {
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-green-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(5 122 85 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-red-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(200 30 30 / var(--tw-bg-opacity));
@ -2704,6 +2827,11 @@ th label {
--tw-ring-color: rgb(63 131 248 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-green-500:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(14 159 110 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:inline {
display: inline;
@ -2721,6 +2849,10 @@ th label {
max-width: 36rem;
}
.sm\:rounded-lg {
border-radius: 0.5rem;
}
.sm\:px-4 {
padding-left: 1rem;
padding-right: 1rem;
@ -2748,10 +2880,18 @@ th label {
}
@media (min-width: 768px) {
.md\:mb-0 {
margin-bottom: 0px;
}
.md\:block {
display: block;
}
.md\:inline {
display: inline;
}
.md\:w-auto {
width: auto;
}
@ -2793,3 +2933,17 @@ th label {
.rtl\:space-x-reverse:where([dir="rtl"], [dir="rtl"] *) > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 1;
}
.rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) {
text-align: right;
}
.\[\&\:first-of-type_button\]\:rounded-s-lg:first-of-type button {
border-start-start-radius: 0.5rem;
border-end-start-radius: 0.5rem;
}
.\[\&\:last-of-type_button\]\:rounded-e-lg:last-of-type button {
border-start-end-radius: 0.5rem;
border-end-end-radius: 0.5rem;
}

View File

@ -1,3 +1,9 @@
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

@ -0,0 +1,20 @@
{% var color=color|default:"gray" %}
<a href="{{ href }}"
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
{% if color == "gray" %}
<button type="button"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ text }}
</button>
{% elif color == "red" %}
<button type="button"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ text }}
</button>
{% elif color == "green" %}
<button type="button"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white">
{{ text }}
</button>
{% endif %}
</a>

View File

@ -0,0 +1,5 @@
<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

@ -0,0 +1,48 @@
<div class="relative overflow-x-auto shadow-md sm:rounded-lg"
hx-boost="false">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
<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 %}
</tbody>
</table>
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between pt-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.paginator.page_range.start }}-{{ page_obj.paginator.page_range.stop }}</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 }}"
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"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg dark:bg-gray-900 dark:border-gray-700 dark:text-gray-400">Previous</a>
{% endif %}
{% for page in elided_page_range %}
<li>
{% if page != page_obj.number %}
<a href="?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"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-400">{{ page }}</a>
{% endif %}
</li>
{% endfor %}
{% if page_obj.has_next %}
<a href="?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"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg dark:bg-gray-900 dark:border-gray-700 dark:text-gray-400">Next</a>
{% endif %}
</li>
</ul>
</nav>
</div>

View File

@ -0,0 +1,12 @@
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
<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>
{{ children }}
</tbody>
</table>
</div>

View File

@ -0,0 +1,15 @@
{% 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 border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border-b">
{{ children|default:default_content }}
</tr>

View File

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

View File

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
{% simple_table columns=data.columns rows=data.rows page_obj=page_obj elided_page_range=elided_page_range %}
{% endblock content %}

View File

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
@ -15,7 +15,7 @@
hx-target=".responsive-table tbody"
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}">
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
{% include "components/button_start.html" with text=last.purchase title="Start session of last played game" only %}
</a>
</div>
{% endif %}

View File

@ -100,12 +100,16 @@
{% if edition.wikidata %}
<span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" />
<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 %}
{% include "components/edit_button.html" with edit_url=edit_url %}
</li>
<ul>
{% for purchase in edition.game_purchases %}
@ -113,14 +117,14 @@
{{ 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 %}
{% 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 %}
{% include "components/edit_button.html" with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
@ -151,7 +155,7 @@
{% 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 %}
{% 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"

View File

@ -1,6 +1,6 @@
import markdown
from django import template
from django.utils.safestring import mark_safe
import markdown
register = template.Library()

View File

@ -1,6 +1,6 @@
from django.urls import path
from games import views
from games import purchaseviews, views
urlpatterns = [
path("", views.index, name="index"),
@ -25,6 +25,11 @@ urlpatterns = [
views.delete_purchase,
name="delete_purchase",
),
path(
"purchase/list",
purchaseviews.list_purchases,
name="list_purchases",
),
path(
"purchase/related-purchase-by-edition",
views.related_purchase_by_edition,

View File

@ -1,29 +1,19 @@
from datetime import datetime
from typing import Any, Callable
from typing import Any, Callable, TypedDict
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, Prefetch, 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 redirect, render
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.shortcuts import get_object_or_404
from common.time import format_duration
from common.utils import safe_division, safe_getattr
@ -36,10 +26,13 @@ from .forms import (
PurchaseForm,
SessionForm,
)
from .models import Edition, Game, Platform, Purchase, Session
from .models import Edition, Game, Platform, Purchase, PurchaseQueryset, Session
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
def model_counts(request):
def model_counts(request: HttpRequest) -> dict[str, bool]:
return {
"game_available": Game.objects.exists(),
"edition_available": Edition.objects.exists(),
@ -49,15 +42,15 @@ def model_counts(request):
}
def stats_dropdown_year_range(request):
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, purchase_id=None):
def add_session(request: HttpRequest, purchase_id: int) -> HttpResponse:
context = {}
initial = {"timestamp_start": timezone.now()}
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last()
if last != None:
@ -105,9 +98,9 @@ def use_custom_redirect(
@login_required
@use_custom_redirect
def edit_session(request, session_id=None):
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
context = {}
session = Session.objects.get(id=session_id)
session = get_object_or_404(Session, id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
@ -119,25 +112,25 @@ def edit_session(request, session_id=None):
@login_required
@use_custom_redirect
def edit_purchase(request, purchase_id=None):
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
context = {}
purchase = Purchase.objects.get(id=purchase_id)
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"] = purchase_id
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, game_id=None):
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
context = {}
purchase = Game.objects.get(id=game_id)
purchase = get_object_or_404(Game, id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
@ -148,23 +141,23 @@ def edit_game(request, game_id=None):
@login_required
def delete_game(request, game_id=None):
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, game_id=None):
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch = Prefetch(
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(
game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
"purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
@ -229,9 +222,9 @@ def view_game(request, game_id=None):
@login_required
@use_custom_redirect
def edit_platform(request, platform_id=None):
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
context = {}
purchase = Platform.objects.get(id=platform_id)
purchase = get_object_or_404(Purchase, id=platform_id)
form = PlatformForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
@ -243,9 +236,9 @@ def edit_platform(request, platform_id=None):
@login_required
@use_custom_redirect
def edit_edition(request, edition_id=None):
def edit_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
context = {}
edition = Edition.objects.get(id=edition_id)
edition = get_object_or_404(Edition, id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
@ -255,7 +248,7 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context)
def related_purchase_by_edition(request):
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
@ -279,7 +272,9 @@ def clone_session_by_id(session_id: int) -> Session:
@login_required
@use_custom_redirect
def new_session_from_existing_session(request, session_id: int, template: str = ""):
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 = {
@ -292,7 +287,9 @@ def new_session_from_existing_session(request, session_id: int, template: str =
@login_required
@use_custom_redirect
def end_session(request, session_id: int, template: str = ""):
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()
@ -306,7 +303,7 @@ def end_session(request, session_id: int, template: str = ""):
@login_required
def delete_session(request, session_id=None):
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")
@ -314,14 +311,14 @@ def delete_session(request, session_id=None):
@login_required
def list_sessions(
request,
filter="",
purchase_id="",
platform_id="",
game_id="",
edition_id="",
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"
@ -365,7 +362,7 @@ def list_sessions(
@login_required
def stats_alltime(request):
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().select_related("purchase__edition")
this_year_sessions_with_durations = this_year_sessions.annotate(
@ -433,7 +430,7 @@ def stats_alltime(request):
* 100
)
purchases_finished_this_year = Purchase.objects.finished()
purchases_finished_this_year: BaseManager[Purchase] = Purchase.objects.finished()
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.all().order_by("date_finished")
)
@ -502,7 +499,7 @@ def stats_alltime(request):
last_play_date = last_session.timestamp_start.strftime("%x")
all_purchased_this_year_count = this_year_purchases_with_currency.count()
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
@ -577,7 +574,7 @@ def stats_alltime(request):
@login_required
def stats(request, year: int = 0):
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
@ -718,6 +715,8 @@ def stats(request, year: int = 0):
first_play_date = "N/A"
last_play_date = "N/A"
first_play_game = None
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game
@ -825,15 +824,15 @@ def stats(request, year: int = 0):
@login_required
def delete_purchase(request, purchase_id=None):
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, edition_id=None):
context = {}
def add_purchase(request: HttpRequest, edition_id: int) -> HttpResponse:
context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
@ -868,8 +867,8 @@ def add_purchase(request, edition_id=None):
@login_required
def add_game(request):
context = {}
def add_game(request: HttpRequest) -> HttpResponse:
context: dict[str, Any] = {}
form = GameForm(request.POST or None)
if form.is_valid():
game = form.save()
@ -887,8 +886,8 @@ def add_game(request):
@login_required
def add_edition(request, game_id=None):
context = {}
def add_edition(request: HttpRequest, game_id: int) -> HttpResponse:
context: dict[str, Any] = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
@ -903,7 +902,7 @@ def add_edition(request, game_id=None):
return redirect("index")
else:
if game_id:
game = Game.objects.get(id=game_id)
game = get_object_or_404(Game, id=game_id)
form = EditionForm(
initial={
"game": game,
@ -922,8 +921,8 @@ def add_edition(request, game_id=None):
@login_required
def add_platform(request):
context = {}
def add_platform(request: HttpRequest) -> HttpResponse:
context: dict[str, Any] = {}
form = PlatformForm(request.POST or None)
if form.is_valid():
form.save()
@ -935,8 +934,8 @@ def add_platform(request):
@login_required
def add_device(request):
context = {}
def add_device(request: HttpRequest) -> HttpResponse:
context: dict[str, Any] = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
@ -948,5 +947,5 @@ def add_device(request):
@login_required
def index(request):
def index(request: HttpRequest) -> HttpResponse:
return redirect("list_sessions_recent")

View File

@ -5,10 +5,13 @@
pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
(poetry.override { python3 = python312; })
python3
poetry
];
shellHook = ''
python -m venv .venv
. .venv/bin/activate
poetry install
'';
}

View File

@ -13,6 +13,7 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views