Compare commits
3 Commits
c8a3212b77
...
f31280c682
Author | SHA1 | Date |
---|---|---|
Lukáš Kucharczyk | f31280c682 | |
Lukáš Kucharczyk | a745d16ec3 | |
Lukáš Kucharczyk | ae079e36ec |
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
]
|
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue