10 Commits

Author SHA1 Message Date
3f037b4c7c Only allow choosing purchases of selected edition
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-15 14:25:42 +01:00
8783d1fc8e Name and related_purchase validation for non-games 2023-11-15 13:04:47 +01:00
9a1d24dbfd Sort imports, remove cruft 2023-11-15 12:19:31 +01:00
4720660cff Fix wrong playrange ordering 2023-11-15 10:40:52 +01:00
e158bc0623 Improve how editions and purchases are displayed
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-15 10:37:24 +01:00
8982fc5086 Game View: order editions by year 2023-11-14 21:19:36 +01:00
729e1d939b Version 1.5.1
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-11-14 21:10:42 +01:00
2b4683e489 Improve and cleanup ConditionalElementHandler 2023-11-14 21:09:43 +01:00
cce810e8cf Improve purchase __str__ 2023-11-14 19:55:56 +01:00
62cd17f702 Disallow choosing non-game purchase as related purchase 2023-11-14 19:55:19 +01:00
17 changed files with 303 additions and 123 deletions

View File

@ -1,3 +1,15 @@
## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
* add purchase: only allow choosing purchases of selected edition
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00 ## 1.5.0 / 2023-11-14 19:27+01:00
## New ## New

View File

@ -6,7 +6,7 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.5.0 ENV VERSION_NUMBER 1.5.1
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -72,6 +72,10 @@ textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400; @apply dark:bg-slate-700 dark:text-slate-400;
} }
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from django.urls import reverse
from games.models import Game, Platform, Purchase, Session, Edition, Device from games.models import Game, Platform, Purchase, Session, Edition, Device
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
@ -50,13 +50,30 @@ class IncludePlatformSelect(forms.Select):
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField( edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField( related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name") queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False,
) )
class Meta: class Meta:
@ -80,6 +97,27 @@ class PurchaseForm(forms.ModelForm):
"name", "name",
] ]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select): class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]

View File

@ -1,11 +1,10 @@
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from common.time import format_duration from common.time import format_duration
from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import F, Manager, Sum from django.db.models import F, Manager, Sum
from zoneinfo import ZoneInfo
class Game(models.Model): class Game(models.Model):
@ -120,18 +119,26 @@ class Purchase(models.Model):
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField( name = models.CharField(max_length=255, default="", null=True, blank=True)
max_length=255, default="Unknown Name", null=True, blank=True
)
related_purchase = models.ForeignKey( related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True "Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
) )
def __str__(self): def __str__(self):
platform_info = self.platform additional_info = [
if self.platform != self.edition.platform: self.get_type_display() if self.type != Purchase.GAME else "",
platform_info = f"{self.edition.platform} version on {self.platform}" f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})" if self.platform != self.edition.platform
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
@ -139,6 +146,10 @@ class Purchase(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type == Purchase.GAME: if self.type == Purchase.GAME:
self.name = "" self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@ -1231,6 +1231,19 @@ textarea:disabled) {
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -1429,6 +1442,10 @@ th label {
padding-right: 1rem; padding-right: 1rem;
} }
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 { .sm\:pl-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
} }

View File

@ -1,4 +1,9 @@
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js"; import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenFalse,
} from "./utils.js";
let syncData = [ let syncData = [
{ {
@ -11,21 +16,15 @@ let syncData = [
syncSelectInputUntilChanged(syncData, "form"); syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
let myConfig = [ disableElementsWhenTrue("#id_type", "game", [
() => { "#id_name",
return getEl("#id_type").value == "game"; "#id_related_purchase",
}, ]);
["#id_name", "#id_related_purchase"], disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
} }
]
document.DOMContentLoaded = conditionalElementHandler(...myConfig) document.addEventListener("DOMContentLoaded", setupElementHandlers);
getEl("#id_type").onchange = () => { getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig) setupElementHandlers();
} };

View File

@ -99,37 +99,72 @@ function getEl(selector) {
return document.getElementsByClassName(selector) return document.getElementsByClassName(selector)
} }
else { else {
return document.getElementsByName(selector) return document.getElementsByTagName(selector)
} }
} }
/** /**
* @description Does something to elements when something happens. * @description Applies different behaviors to elements based on multiple conditional configurations.
* @param {() => boolean} condition The condition that is being tested. * Each configuration is an array containing a condition function, an array of target element selectors,
* @param {string[]} targetElements * and two callback functions for handling matched and unmatched conditions.
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches. * @param {...Array} configs Each configuration is an array of the form:
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match. * - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/ */
function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) { function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) { if (condition()) {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error("Element ${elementName} doesn't exist."); console.error(`Element ${elementName} doesn't exist.`);
} else { } else {
callbackfn1(el); callbackfn1(el);
} }
}); });
} else { } else {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error("Element ${elementName} doesn't exist."); console.error(`Element ${elementName} doesn't exist.`);
} else { } else {
callbackfn2(el); callbackfn2(el);
} }
}); });
} }
});
} }
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler }; function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value != targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };

View File

@ -13,7 +13,7 @@
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark"> <body class="dark" hx-indicator="#indicator">
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" /> <img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
<div class="dark:bg-gray-800 min-h-screen"> <div class="dark:bg-gray-800 min-h-screen">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">

View File

@ -12,7 +12,6 @@
id="last-session-start" id="last-session-start"
href="{% url 'start_session_same_as_last' last.id %}" href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session_same_as_last' last.id %}" hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-indicator="#indicator"
hx-swap="afterbegin" hx-swap="afterbegin"
hx-target=".responsive-table tbody" hx-target=".responsive-table tbody"
hx-select=".responsive-table tbody tr:first-child" hx-select=".responsive-table tbody tr:first-child"

View File

@ -0,0 +1 @@
{{ form.related_purchase }}

View File

@ -13,11 +13,12 @@
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</h1> </h1>
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span> {{ hours_sum }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span> {{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) </h2> ({{ playrange }}) </h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1> <h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span></h1>
<ul> <ul>
{% for edition in editions %} {% for edition in editions %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-2 flex items-center">
@ -32,32 +33,27 @@
{% url 'edit_edition' edition.id as edit_url %} {% 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> </li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul> <ul>
{% for purchase in purchases %} {% for purchase in edition.game_purchases %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-6 flex items-center">
{{ purchase.platform }} {{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %} {% 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 %}
{% if purchase.related_purchases %} </li>
<li>
<ul> <ul>
{% for related_purchase in purchase.related_purchases %} {% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-6 flex items-center"> <li class="sm:pl-12 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}}) {{ 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 %} {% 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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</li>
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions Sessions
<span class="dark:text-slate-500"> <span class="dark:text-slate-500">

View File

@ -44,6 +44,11 @@ urlpatterns = [
views.add_purchase, views.add_purchase,
name="add_purchase_for_edition", name="add_purchase_for_edition",
), ),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"), path("add-edition/", views.add_edition, name="add_edition"),
path( path(
"add-edition-for-game/<int:game_id>", "add-edition-for-game/<int:game_id>",

View File

@ -2,7 +2,7 @@ from common.time import format_duration, now as now_with_tz
from common.utils import safe_division from common.utils import safe_division
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.db.models import Sum, F, Count from django.db.models import Sum, F, Count, Prefetch
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
@ -136,44 +136,54 @@ def edit_game(request, game_id=None):
def view_game(request, game_id=None): def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
context["title"] = "View Game" nongame_related_purchases_prefetch = Prefetch(
context["game"] = game "related_purchases",
context["editions"] = Edition.objects.filter(game_id=game_id) queryset=Purchase.objects.exclude(type=Purchase.GAME),
game_purchases = ( to_attr="nongame_related_purchases",
Purchase.objects.filter(edition__game_id=game_id) )
.filter(type=Purchase.GAME) game_purchases_prefetch = Prefetch(
.order_by("date_purchased") "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")
) )
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases sessions = Session.objects.filter(purchase__edition__game=game).order_by(
context["sessions"] = Session.objects.filter( "timestamp_start"
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
) )
context["session_average"] = round( session_count = sessions.count()
(context["total_hours"]) / int(context["sessions"].count()), 1
)
# here first and last is flipped
# because sessions are ordered from newest to oldest
# so the most recent are on top
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y")
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y")
context["playrange"] = ( playrange_start = sessions.first().timestamp_start.strftime("%b %Y")
playrange_end = sessions.last().timestamp_start.strftime("%b %Y")
playrange = (
playrange_start playrange_start
if playrange_start == playrange_end if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}" else f"{playrange_start}{playrange_end}"
) )
total_hours = float(format_duration(sessions.total_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": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes": sessions.exclude(note=""),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
}
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@ -204,6 +214,15 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
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})
@use_custom_redirect @use_custom_redirect
def start_game_session(request, game_id: int): def start_game_session(request, game_id: int):
last_session = ( last_session = (

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.0" version = "1.5.1"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"