Compare commits
11 Commits
1.4.0
...
d211326c3f
Author | SHA1 | Date | |
---|---|---|---|
d211326c3f | |||
270a291f05 | |||
13b750ca92 | |||
015b6db2f7 | |||
667b161fff | |||
5958cbf4a6 | |||
3b37f2c3f0 | |||
4517ff2b5a | |||
884ce13e26 | |||
dd219bae9d | |||
60d29090a1 |
@ -1,4 +1,9 @@
|
||||
## Unreleased
|
||||
### Unreleased
|
||||
|
||||
## New
|
||||
* Add stat for finished this year's games
|
||||
|
||||
## 1.4.0 / 2023-11-09 21:01+01:00
|
||||
|
||||
### New
|
||||
* More fields are now optional. This is to make it easier to add new items in bulk.
|
||||
@ -23,6 +28,8 @@
|
||||
* Unfinished (count)
|
||||
* Refunded (count)
|
||||
* Backlog Decrease (count)
|
||||
* New workflow:
|
||||
* Adding Game, Edition, Purchase, and Session in a row is now much faster
|
||||
|
||||
### Improved
|
||||
* game overview: simplify playtime range display
|
||||
|
@ -6,7 +6,7 @@ RUN npm install && \
|
||||
|
||||
FROM python:3.10.9-slim-bullseye
|
||||
|
||||
ENV VERSION_NUMBER 1.3.0
|
||||
ENV VERSION_NUMBER 1.4.0
|
||||
ENV PROD 1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
|
@ -44,7 +44,7 @@ def format_duration(
|
||||
# timestamps where end is before start
|
||||
if seconds_total < 0:
|
||||
seconds_total = 0
|
||||
days = hours = minutes = seconds = 0
|
||||
days = hours = hours_float = minutes = seconds = 0
|
||||
remainder = seconds = seconds_total
|
||||
if "%d" in format_string:
|
||||
days, remainder = divmod(seconds_total, day_seconds)
|
||||
@ -55,7 +55,7 @@ def format_duration(
|
||||
minutes, seconds = divmod(remainder, minute_seconds)
|
||||
literals = {
|
||||
"d": str(days),
|
||||
"H": str(hours),
|
||||
"H": str(hours) if "m" not in format_string else str(hours_float),
|
||||
"m": str(minutes),
|
||||
"s": str(seconds),
|
||||
"r": str(seconds_total),
|
||||
|
@ -151,7 +151,7 @@ class Session(models.Model):
|
||||
objects = SessionQuerySet.as_manager()
|
||||
|
||||
def __str__(self):
|
||||
mark = ", manual" if self.duration_manual != None else ""
|
||||
mark = ", manual" if self.is_manual() else ""
|
||||
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
|
||||
def finish_now(self):
|
||||
@ -163,7 +163,7 @@ class Session(models.Model):
|
||||
def duration_seconds(self) -> timedelta:
|
||||
manual = timedelta(0)
|
||||
calculated = timedelta(0)
|
||||
if not self.duration_manual in (None, 0, timedelta(0)):
|
||||
if self.is_manual():
|
||||
manual = self.duration_manual
|
||||
if self.timestamp_end != None and self.timestamp_start != None:
|
||||
calculated = self.timestamp_end - self.timestamp_start
|
||||
@ -173,6 +173,9 @@ class Session(models.Model):
|
||||
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
|
||||
return result
|
||||
|
||||
def is_manual(self) -> bool:
|
||||
return not self.duration_manual == timedelta(0)
|
||||
|
||||
@property
|
||||
def duration_sum(self) -> str:
|
||||
return Session.objects.all().total_duration_formatted()
|
||||
|
29
games/templates/add_edition.html
Normal file
29
games/templates/add_edition.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<table class="mx-auto">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_table }}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" name="submit" value="Submit"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Purchase"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if script_name %}
|
||||
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||
{% endif %}
|
||||
{% endblock scripts %}
|
||||
|
29
games/templates/add_game.html
Normal file
29
games/templates/add_game.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<table class="mx-auto">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_table }}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" name="submit" value="Submit"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Edition"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if script_name %}
|
||||
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||
{% endif %}
|
||||
{% endblock scripts %}
|
||||
|
29
games/templates/add_purchase.html
Normal file
29
games/templates/add_purchase.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<table class="mx-auto">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_table }}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" name="submit" value="Submit"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Session"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if script_name %}
|
||||
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||
{% endif %}
|
||||
{% endblock scripts %}
|
||||
|
@ -45,6 +45,10 @@
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -13,6 +13,11 @@ urlpatterns = [
|
||||
path("add-game/", views.add_game, name="add_game"),
|
||||
path("add-platform/", views.add_platform, name="add_platform"),
|
||||
path("add-session/", views.add_session, name="add_session"),
|
||||
path(
|
||||
"add-session-for-purchase/<int:purchase_id>",
|
||||
views.add_session,
|
||||
name="add_session_for_purchase",
|
||||
),
|
||||
path(
|
||||
"update-session/by-session/<int:session_id>",
|
||||
views.update_session,
|
||||
@ -34,7 +39,17 @@ urlpatterns = [
|
||||
# name="delete_session",
|
||||
# ),
|
||||
path("add-purchase/", views.add_purchase, name="add_purchase"),
|
||||
path(
|
||||
"add-purchase-for-edition/<int:edition_id>",
|
||||
views.add_purchase,
|
||||
name="add_purchase_for_edition",
|
||||
),
|
||||
path("add-edition/", views.add_edition, name="add_edition"),
|
||||
path(
|
||||
"add-edition-for-game/<int:game_id>",
|
||||
views.add_edition,
|
||||
name="add_edition_for_game",
|
||||
),
|
||||
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
|
||||
path("game/<int:game_id>/view", views.view_game, name="view_game"),
|
||||
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
|
||||
|
128
games/views.py
128
games/views.py
@ -32,24 +32,38 @@ def model_counts(request):
|
||||
|
||||
|
||||
def stats_dropdown_year_range(request):
|
||||
return {"stats_dropdown_year_range": range(2018, 2024)}
|
||||
result = {
|
||||
"stats_dropdown_year_range": range(
|
||||
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
|
||||
)
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def add_session(request):
|
||||
def add_session(request, purchase_id=None):
|
||||
context = {}
|
||||
initial = {}
|
||||
|
||||
now = now_with_tz()
|
||||
initial["timestamp_start"] = now
|
||||
initial = {"timestamp_start": now_with_tz()}
|
||||
|
||||
last = Session.objects.all().last()
|
||||
if last != None:
|
||||
initial["purchase"] = last.purchase
|
||||
|
||||
form = SessionForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("list_sessions")
|
||||
if request.method == "POST":
|
||||
form = SessionForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("list_sessions")
|
||||
else:
|
||||
if purchase_id:
|
||||
purchase = Purchase.objects.get(id=purchase_id)
|
||||
form = SessionForm(
|
||||
initial={
|
||||
**initial,
|
||||
"purchase": purchase,
|
||||
}
|
||||
)
|
||||
else:
|
||||
form = SessionForm(initial=initial)
|
||||
|
||||
context["title"] = "Add New Session"
|
||||
context["form"] = form
|
||||
@ -322,7 +336,7 @@ def stats(request, year: int = 0):
|
||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||
total_spent=Sum(F("price"))
|
||||
)
|
||||
total_spent = this_year_spendings["total_spent"]
|
||||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||
@ -371,9 +385,15 @@ def stats(request, year: int = 0):
|
||||
"spent_per_game": int(
|
||||
safe_division(total_spent, this_year_purchases_without_refunded.count())
|
||||
),
|
||||
"all_finished_this_year": purchases_finished_this_year,
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year,
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year,
|
||||
"all_finished_this_year": purchases_finished_this_year.order_by(
|
||||
"date_finished"
|
||||
),
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
|
||||
"date_finished"
|
||||
),
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
|
||||
"date_finished"
|
||||
),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||
@ -397,45 +417,91 @@ def stats(request, year: int = 0):
|
||||
return render(request, "stats.html", context)
|
||||
|
||||
|
||||
def add_purchase(request):
|
||||
def add_purchase(request, edition_id=None):
|
||||
context = {}
|
||||
now = datetime.now()
|
||||
initial = {"date_purchased": now}
|
||||
form = PurchaseForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("index")
|
||||
initial = {"date_purchased": now_with_tz()}
|
||||
|
||||
if request.method == "POST":
|
||||
form = PurchaseForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
purchase = form.save()
|
||||
if "submit_and_redirect" in request.POST:
|
||||
return HttpResponseRedirect(
|
||||
reverse(
|
||||
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect("index")
|
||||
else:
|
||||
if edition_id:
|
||||
edition = Edition.objects.get(id=edition_id)
|
||||
form = PurchaseForm(
|
||||
initial={
|
||||
**initial,
|
||||
"edition": edition,
|
||||
"platform": edition.platform,
|
||||
}
|
||||
)
|
||||
else:
|
||||
form = PurchaseForm(initial=initial)
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Purchase"
|
||||
context["script_name"] = "add_purchase.js"
|
||||
return render(request, "add.html", context)
|
||||
return render(request, "add_purchase.html", context)
|
||||
|
||||
|
||||
def add_game(request):
|
||||
context = {}
|
||||
form = GameForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("index")
|
||||
game = form.save()
|
||||
if "submit_and_redirect" in request.POST:
|
||||
return HttpResponseRedirect(
|
||||
reverse("add_edition_for_game", kwargs={"game_id": game.id})
|
||||
)
|
||||
else:
|
||||
return redirect("index")
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Game"
|
||||
context["script_name"] = "add_game.js"
|
||||
return render(request, "add.html", context)
|
||||
return render(request, "add_game.html", context)
|
||||
|
||||
|
||||
def add_edition(request):
|
||||
def add_edition(request, game_id=None):
|
||||
context = {}
|
||||
form = EditionForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("index")
|
||||
if request.method == "POST":
|
||||
form = EditionForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
edition = form.save()
|
||||
if "submit_and_redirect" in request.POST:
|
||||
return HttpResponseRedirect(
|
||||
reverse(
|
||||
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect("index")
|
||||
else:
|
||||
if game_id:
|
||||
game = Game.objects.get(id=game_id)
|
||||
form = EditionForm(
|
||||
initial={
|
||||
"game": game,
|
||||
"name": game.name,
|
||||
"sort_name": game.sort_name,
|
||||
"year_released": game.year_released,
|
||||
}
|
||||
)
|
||||
else:
|
||||
form = EditionForm()
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Edition"
|
||||
context["script_name"] = "add_edition.js"
|
||||
return render(request, "add.html", context)
|
||||
return render(request, "add_edition.html", context)
|
||||
|
||||
|
||||
def add_platform(request):
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "timetracker"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
description = "A simple time tracker."
|
||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
||||
license = "GPL"
|
||||
|
89
tests/test_paths_return_200.py
Normal file
89
tests/test_paths_return_200.py
Normal file
@ -0,0 +1,89 @@
|
||||
import django
|
||||
import os
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
|
||||
from games.models import Game, Edition, Purchase, Session, Platform
|
||||
|
||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
||||
class PathWorksTest(TestCase):
|
||||
def setUp(self) -> None:
|
||||
pl = Platform(name="Test Platform")
|
||||
pl.save()
|
||||
g = Game(name="The Test Game")
|
||||
g.save()
|
||||
e = Edition(game=g, name="The Test Game Edition", platform=pl)
|
||||
e.save()
|
||||
p = Purchase(
|
||||
edition=e,
|
||||
platform=pl,
|
||||
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||
)
|
||||
p.save()
|
||||
s = Session(
|
||||
purchase=p,
|
||||
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
|
||||
)
|
||||
s.save()
|
||||
self.testSession = s
|
||||
return super().setUp()
|
||||
|
||||
def test_add_device_returns_200(self):
|
||||
url = reverse("add_device")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_platform_returns_200(self):
|
||||
url = reverse("add_platform")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_game_returns_200(self):
|
||||
url = reverse("add_game")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_edition_returns_200(self):
|
||||
url = reverse("add_edition")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_purchase_returns_200(self):
|
||||
url = reverse("add_purchase")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_session_returns_200(self):
|
||||
url = reverse("add_session")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_edit_session_returns_200(self):
|
||||
id = self.testSession.id
|
||||
url = reverse("edit_session", args=[id])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_view_game_returns_200(self):
|
||||
url = reverse("view_game", args=[1])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_edit_game_returns_200(self):
|
||||
url = reverse("edit_game", args=[1])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_list_sessions_returns_200(self):
|
||||
url = reverse("list_sessions")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
38
tests/test_session_formatting.py
Normal file
38
tests/test_session_formatting.py
Normal file
@ -0,0 +1,38 @@
|
||||
import django
|
||||
import os
|
||||
from django.test import TestCase
|
||||
from django.db import models
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
from games.models import Game, Edition, Purchase, Session
|
||||
|
||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
||||
class FormatDurationTest(TestCase):
|
||||
def setUp(self) -> None:
|
||||
return super().setUp()
|
||||
|
||||
def test_duration_format(self):
|
||||
g = Game(name="The Test Game")
|
||||
g.save()
|
||||
e = Edition(game=g, name="The Test Game Edition")
|
||||
e.save()
|
||||
p = Purchase(
|
||||
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
|
||||
)
|
||||
p.save()
|
||||
s = Session(
|
||||
purchase=p,
|
||||
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
|
||||
)
|
||||
s.save()
|
||||
self.assertEqual(
|
||||
s.duration_formatted(),
|
||||
"02:40",
|
||||
)
|
@ -83,6 +83,16 @@ class FormatDurationTest(unittest.TestCase):
|
||||
result = format_duration(delta, "%r seconds")
|
||||
self.assertEqual(result, "0 seconds")
|
||||
|
||||
def test_specific(self):
|
||||
delta = timedelta(hours=2, minutes=40)
|
||||
result = format_duration(delta, "%H:%m")
|
||||
self.assertEqual(result, "2:40")
|
||||
|
||||
def test_specific_precise_if_unncessary(self):
|
||||
delta = timedelta(hours=2, minutes=40)
|
||||
result = format_duration(delta, "%02.0H:%02.0m")
|
||||
self.assertEqual(result, "02:40")
|
||||
|
||||
def test_all_at_once(self):
|
||||
delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
|
||||
result = format_duration(
|
||||
|
@ -150,5 +150,3 @@ if _csrf_trusted_origins:
|
||||
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
|
||||
else:
|
||||
CSRF_TRUSTED_ORIGINS = []
|
||||
|
||||
USE_L10N = False
|
||||
|
Reference in New Issue
Block a user