Compare commits

...

3 Commits

Author SHA1 Message Date
Lukáš Kucharczyk f31280c682 Version 1.5.0
continuous-integration/drone/push Build is passing Details
2023-11-14 19:31:17 +01:00
Lukáš Kucharczyk a745d16ec3 Order purchases by date on game view 2023-11-14 19:30:19 +01:00
Lukáš Kucharczyk ae079e36ec Add purchase types 2023-11-14 19:27:00 +01:00
15 changed files with 249 additions and 14 deletions

View File

@ -1,7 +1,15 @@
### Unreleased ## 1.5.0 / 2023-11-14 19:27+01:00
## New ## New
* Add stat for finished this year's games * Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00 ## 1.4.0 / 2023-11-09 21:01+01:00

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.4.0 ENV VERSION_NUMBER 1.5.0
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -66,6 +66,12 @@ textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} }
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,

View File

@ -55,6 +55,9 @@ class PurchaseForm(forms.ModelForm):
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(
queryset=Purchase.objects.order_by("edition__sort_name")
)
class Meta: class Meta:
widgets = { widgets = {
@ -72,6 +75,9 @@ class PurchaseForm(forms.ModelForm):
"price", "price",
"price_currency", "price_currency",
"ownership_type", "ownership_type",
"type",
"related_purchase",
"name",
] ]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]

View File

@ -71,6 +71,9 @@ class PurchaseQueryset(models.QuerySet):
def finished(self): def finished(self):
return self.filter(date_finished__isnull=False) return self.filter(date_finished__isnull=False)
def games_only(self):
return self.filter(type=Purchase.GAME)
class Purchase(models.Model): class Purchase(models.Model):
PHYSICAL = "ph" PHYSICAL = "ph"
@ -91,6 +94,16 @@ class Purchase(models.Model):
(DEMO, "Demo"), (DEMO, "Demo"),
(PIRATED, "Pirated"), (PIRATED, "Pirated"),
] ]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
objects = PurchaseQueryset().as_manager() objects = PurchaseQueryset().as_manager()
@ -106,6 +119,13 @@ class Purchase(models.Model):
ownership_type = models.CharField( ownership_type = models.CharField(
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)
name = models.CharField(
max_length=255, default="Unknown Name", null=True, blank=True
)
related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
)
def __str__(self): def __str__(self):
platform_info = self.platform platform_info = self.platform
@ -113,6 +133,14 @@ class Purchase(models.Model):
platform_info = f"{self.edition.platform} version on {self.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()})" return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
super().save(*args, **kwargs)
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View File

@ -1222,6 +1222,15 @@ textarea) {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
:is(.dark form input:disabled),:is(.dark
select:disabled),:is(.dark
textarea:disabled) {
--tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -1428,6 +1437,10 @@ th label {
padding-left: 1rem; padding-left: 1rem;
} }
.sm\:pl-6 {
padding-left: 1.5rem;
}
.sm\:decoration-2 { .sm\:decoration-2 {
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }

View File

@ -1,12 +1,31 @@
import { syncSelectInputUntilChanged } from './utils.js' import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
let syncData = [ let syncData = [
{ {
"source": "#id_edition", source: "#id_edition",
"source_value": "dataset.platform", source_value: "dataset.platform",
"target": "#id_platform", target: "#id_platform",
"target_value": "value" target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
let myConfig = [
() => {
return getEl("#id_type").value == "game";
},
["#id_name", "#id_related_purchase"],
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
} }
] ]
syncSelectInputUntilChanged(syncData, "form") document.DOMContentLoaded = conditionalElementHandler(...myConfig)
getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig)
}

View File

@ -87,4 +87,49 @@ function getValueFromProperty(sourceElement, property) {
} }
} }
export { toISOUTCString, syncSelectInputUntilChanged }; /**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1))
}
else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector)
}
else {
return document.getElementsByName(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.
*/
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);
}
});
}
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler };

View File

@ -181,7 +181,14 @@
<tbody> <tbody>
{% for purchase in all_purchased_this_year %} {% for purchase in all_purchased_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type != "game" %}
({{ purchase.name }}, {{ purchase.get_type_display }})
{% endif %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr> </tr>

View File

@ -42,6 +42,19 @@
({{ 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>
<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>
</li>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -118,7 +118,8 @@ def edit_purchase(request, purchase_id=None):
return redirect("list_sessions") return redirect("list_sessions")
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
return render(request, "add.html", context) context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@use_custom_redirect @use_custom_redirect
@ -140,7 +141,17 @@ def view_game(request, game_id=None):
context["title"] = "View Game" context["title"] = "View Game"
context["game"] = game context["game"] = game
context["editions"] = Edition.objects.filter(game_id=game_id) context["editions"] = Edition.objects.filter(game_id=game_id)
context["purchases"] = Purchase.objects.filter(edition__game_id=game_id) game_purchases = (
Purchase.objects.filter(edition__game_id=game_id)
.filter(type=Purchase.GAME)
.order_by("date_purchased")
)
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( context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id purchase__edition__game_id=game_id
).order_by("-timestamp_start") ).order_by("-timestamp_start")
@ -312,7 +323,9 @@ def stats(request, year: int = 0):
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter( this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
date_finished__isnull=True date_finished__isnull=True
) ).filter(
type=Purchase.GAME
) # do not count DLC etc.
this_year_purchases_unfinished_percent = int( this_year_purchases_unfinished_percent = int(
safe_division( safe_division(

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.4.0" version = "1.5.0"
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"