Compare commits
10 Commits
1.5.0
...
3f037b4c7c
Author | SHA1 | Date | |
---|---|---|---|
3f037b4c7c | |||
8783d1fc8e | |||
9a1d24dbfd | |||
4720660cff | |||
e158bc0623 | |||
8982fc5086 | |||
729e1d939b | |||
2b4683e489 | |||
cce810e8cf | |||
62cd17f702 |
12
CHANGELOG.md
12
CHANGELOG.md
@ -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
|
||||
|
||||
## New
|
||||
|
@ -6,7 +6,7 @@ RUN npm install && \
|
||||
|
||||
FROM python:3.10.9-slim-bullseye
|
||||
|
||||
ENV VERSION_NUMBER 1.5.0
|
||||
ENV VERSION_NUMBER 1.5.1
|
||||
ENV PROD 1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
|
@ -72,6 +72,10 @@ textarea:disabled {
|
||||
@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) {
|
||||
form input,
|
||||
select,
|
||||
|
@ -1,5 +1,5 @@
|
||||
from django import forms
|
||||
|
||||
from django.urls import reverse
|
||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
|
||||
|
||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||
@ -50,13 +50,30 @@ class IncludePlatformSelect(forms.Select):
|
||||
|
||||
|
||||
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(
|
||||
queryset=Edition.objects.order_by("sort_name"),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
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:
|
||||
@ -80,6 +97,27 @@ class PurchaseForm(forms.ModelForm):
|
||||
"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):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
|
26
games/migrations/0029_alter_purchase_related_purchase.py
Normal file
26
games/migrations/0029_alter_purchase_related_purchase.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
18
games/migrations/0030_alter_purchase_name.py
Normal file
18
games/migrations/0030_alter_purchase_name.py
Normal 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),
|
||||
),
|
||||
]
|
@ -1,11 +1,10 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from common.time import format_duration
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import F, Manager, Sum
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
@ -120,18 +119,26 @@ class Purchase(models.Model):
|
||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||
)
|
||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||
name = models.CharField(
|
||||
max_length=255, default="Unknown Name", null=True, blank=True
|
||||
)
|
||||
name = models.CharField(max_length=255, default="", null=True, blank=True)
|
||||
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):
|
||||
platform_info = self.platform
|
||||
if self.platform != self.edition.platform:
|
||||
platform_info = f"{self.edition.platform} version on {self.platform}"
|
||||
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
|
||||
additional_info = [
|
||||
self.get_type_display() if self.type != Purchase.GAME else "",
|
||||
f"{self.edition.platform} version on {self.platform}"
|
||||
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):
|
||||
return self.type == self.GAME
|
||||
@ -139,6 +146,10 @@ class Purchase(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
if self.type == Purchase.GAME:
|
||||
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)
|
||||
|
||||
|
||||
|
@ -1231,6 +1231,19 @@ textarea:disabled) {
|
||||
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) {
|
||||
form input,
|
||||
select,
|
||||
@ -1429,6 +1442,10 @@ th label {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.sm\:pl-12 {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.sm\:pl-2 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
@ -1,4 +1,9 @@
|
||||
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenFalse,
|
||||
} from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
@ -11,21 +16,15 @@ let syncData = [
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
|
||||
|
||||
let myConfig = [
|
||||
() => {
|
||||
return getEl("#id_type").value == "game";
|
||||
},
|
||||
["#id_name", "#id_related_purchase"],
|
||||
(el) => {
|
||||
el.disabled = "disabled";
|
||||
},
|
||||
(el) => {
|
||||
el.disabled = "";
|
||||
}
|
||||
]
|
||||
|
||||
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
|
||||
getEl("#id_type").onchange = () => {
|
||||
conditionalElementHandler(...myConfig)
|
||||
function setupElementHandlers() {
|
||||
disableElementsWhenTrue("#id_type", "game", [
|
||||
"#id_name",
|
||||
"#id_related_purchase",
|
||||
]);
|
||||
disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
getEl("#id_type").onchange = () => {
|
||||
setupElementHandlers();
|
||||
};
|
||||
|
@ -99,37 +99,72 @@ function getEl(selector) {
|
||||
return document.getElementsByClassName(selector)
|
||||
}
|
||||
else {
|
||||
return document.getElementsByName(selector)
|
||||
return document.getElementsByTagName(selector)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Does something to elements when something happens.
|
||||
* @param {() => boolean} condition The condition that is being tested.
|
||||
* @param {string[]} targetElements
|
||||
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches.
|
||||
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match.
|
||||
* @description Applies different behaviors to elements based on multiple conditional configurations.
|
||||
* Each configuration is an array containing a condition function, an array of target element selectors,
|
||||
* and two callback functions for handling matched and unmatched conditions.
|
||||
* @param {...Array} configs Each configuration is an array of the form:
|
||||
* - 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) {
|
||||
if (condition()) {
|
||||
targetElements.forEach((elementName) => {
|
||||
let el = getEl(elementName);
|
||||
if (el === null) {
|
||||
console.error("Element ${elementName} doesn't exist.");
|
||||
} else {
|
||||
callbackfn1(el);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
targetElements.forEach((elementName) => {
|
||||
let el = getEl(elementName);
|
||||
if (el === null) {
|
||||
console.error("Element ${elementName} doesn't exist.");
|
||||
} else {
|
||||
callbackfn2(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
function conditionalElementHandler(...configs) {
|
||||
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
|
||||
if (condition()) {
|
||||
targetElements.forEach(elementName => {
|
||||
let el = getEl(elementName);
|
||||
if (el === null) {
|
||||
console.error(`Element ${elementName} doesn't exist.`);
|
||||
} else {
|
||||
callbackfn1(el);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
targetElements.forEach(elementName => {
|
||||
let el = getEl(elementName);
|
||||
if (el === null) {
|
||||
console.error(`Element ${elementName} doesn't exist.`);
|
||||
} else {
|
||||
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 };
|
||||
|
@ -13,7 +13,7 @@
|
||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||
</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" />
|
||||
<div class="dark:bg-gray-800 min-h-screen">
|
||||
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
||||
|
@ -12,7 +12,6 @@
|
||||
id="last-session-start"
|
||||
href="{% 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-target=".responsive-table tbody"
|
||||
hx-select=".responsive-table tbody tr:first-child"
|
||||
|
1
games/templates/partials/related_purchase_field.html
Normal file
1
games/templates/partials/related_purchase_field.html
Normal file
@ -0,0 +1 @@
|
||||
{{ form.related_purchase }}
|
@ -13,51 +13,47 @@
|
||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||
</h1>
|
||||
<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>
|
||||
({{ playrange }}) </h2>
|
||||
<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>
|
||||
{% 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" src="{% static 'icons/wikidata.png' %}"/>
|
||||
</a>
|
||||
</span>
|
||||
<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' %}"/>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
|
||||
<ul>
|
||||
{% for purchase in purchases %}
|
||||
<li class="sm:pl-2 flex items-center">
|
||||
{{ purchase.platform }}
|
||||
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
|
||||
{% url 'edit_purchase' purchase.id as edit_url %}
|
||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||
{% if purchase.related_purchases %}
|
||||
<li>
|
||||
<ul>
|
||||
{% for related_purchase in purchase.related_purchases %}
|
||||
<li class="sm:pl-6 flex items-center">
|
||||
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ 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>
|
||||
<ul>
|
||||
{% for purchase in edition.game_purchases %}
|
||||
<li class="sm:pl-6 flex items-center">
|
||||
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}
|
||||
{% url 'edit_purchase' purchase.id as edit_url %}
|
||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</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">
|
||||
Sessions
|
||||
<span class="dark:text-slate-500">
|
||||
|
@ -44,6 +44,11 @@ urlpatterns = [
|
||||
views.add_purchase,
|
||||
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-for-game/<int:game_id>",
|
||||
|
@ -2,7 +2,7 @@ from common.time import format_duration, now as now_with_tz
|
||||
from common.utils import safe_division
|
||||
from datetime import datetime, timedelta
|
||||
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.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
@ -136,44 +136,54 @@ def edit_game(request, game_id=None):
|
||||
|
||||
|
||||
def view_game(request, game_id=None):
|
||||
context = {}
|
||||
game = Game.objects.get(id=game_id)
|
||||
context["title"] = "View Game"
|
||||
context["game"] = game
|
||||
context["editions"] = Edition.objects.filter(game_id=game_id)
|
||||
game_purchases = (
|
||||
Purchase.objects.filter(edition__game_id=game_id)
|
||||
.filter(type=Purchase.GAME)
|
||||
.order_by("date_purchased")
|
||||
nongame_related_purchases_prefetch = Prefetch(
|
||||
"related_purchases",
|
||||
queryset=Purchase.objects.exclude(type=Purchase.GAME),
|
||||
to_attr="nongame_related_purchases",
|
||||
)
|
||||
game_purchases_prefetch = 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")
|
||||
)
|
||||
for purchase in game_purchases:
|
||||
purchase.related_purchases = Purchase.objects.exclude(
|
||||
type=Purchase.GAME
|
||||
).filter(related_purchase=purchase.id)
|
||||
|
||||
context["purchases"] = game_purchases
|
||||
context["sessions"] = Session.objects.filter(
|
||||
purchase__edition__game_id=game_id
|
||||
).order_by("-timestamp_start")
|
||||
context["total_hours"] = float(
|
||||
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
|
||||
sessions = Session.objects.filter(purchase__edition__game=game).order_by(
|
||||
"timestamp_start"
|
||||
)
|
||||
context["session_average"] = round(
|
||||
(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")
|
||||
session_count = sessions.count()
|
||||
|
||||
context["playrange"] = (
|
||||
playrange_start = sessions.first().timestamp_start.strftime("%b %Y")
|
||||
playrange_end = sessions.last().timestamp_start.strftime("%b %Y")
|
||||
|
||||
playrange = (
|
||||
playrange_start
|
||||
if 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
|
||||
return render(request, "view_game.html", context)
|
||||
|
||||
@ -204,6 +214,15 @@ def edit_edition(request, edition_id=None):
|
||||
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
|
||||
def start_game_session(request, game_id: int):
|
||||
last_session = (
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "timetracker"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
description = "A simple time tracker."
|
||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
||||
license = "GPL"
|
||||
|
Reference in New Issue
Block a user