Show playtime total on session list
continuous-integration/drone/push Build is passing Details

Fixes #6
Fixes #25
This commit is contained in:
Lukáš Kucharczyk 2023-01-09 18:57:22 +01:00
parent 24f4459318
commit b77089f7ad
9 changed files with 104 additions and 58 deletions

View File

@ -1,3 +1,7 @@
## Unreleased
* Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6)
## 0.1.4 / 2023-01-08 15:45+01:00 ## 0.1.4 / 2023-01-08 15:45+01:00
* Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23) * Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23)

View File

@ -6,7 +6,7 @@ RUN npm install && \
FROM python:3.10.9-alpine FROM python:3.10.9-alpine
ENV VERSION_NUMBER 0.1.2-6-g751182d ENV VERSION_NUMBER 0.1.2-7-g24f4459
ENV PROD 1 ENV PROD 1
RUN apk add \ RUN apk add \

View File

@ -8,8 +8,17 @@ def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE)) return datetime.now(ZoneInfo(settings.TIME_ZONE))
def _safe_timedelta(duration: timedelta | int | None):
if duration == None:
return timedelta(0)
elif isinstance(duration, int):
return timedelta(seconds=duration)
elif isinstance(duration, timedelta):
return duration
def format_duration( def format_duration(
duration: timedelta | None, format_string: str = "%H hours %m minutes" duration: timedelta | int | None, format_string: str = "%H hours %m minutes"
) -> str: ) -> str:
""" """
Format timedelta into the specified format_string. Format timedelta into the specified format_string.
@ -22,11 +31,8 @@ def format_duration(
minute_seconds = 60 minute_seconds = 60
hour_seconds = 60 * minute_seconds hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds day_seconds = 24 * hour_seconds
if not isinstance(duration, timedelta): duration = _safe_timedelta(duration)
if duration == None: # we don't need float
duration = timedelta(seconds=0)
else:
duration = timedelta(seconds=duration)
seconds_total = int(duration.total_seconds()) seconds_total = int(duration.total_seconds())
# timestamps where end is before start # timestamps where end is before start
if seconds_total < 0: if seconds_total < 0:

View File

@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-01-09 14:49
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tracker", "0003_alter_session_duration_manual_and_more"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-01-09 17:43
from django.db import migrations
from datetime import timedelta
def set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session")
for session in Session.objects.all():
if session.duration_calculated == None:
session.duration_calculated = timedelta(0)
session.save()
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session")
for session in Session.objects.all():
if session.duration_calculated == timedelta(0):
session.duration_calculated = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("tracker", "0004_alter_session_duration_manual"),
]
operations = [
migrations.RunPython(
set_duration_calculated_none_to_zero,
revert_set_duration_calculated_none_to_zero,
)
]

View File

@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from common.util.time import format_duration from common.util.time import format_duration
from django.db.models import Sum from django.db.models import Sum, F
class Game(models.Model): class Game(models.Model):
@ -32,69 +32,51 @@ class Platform(models.Model):
return self.name return self.name
class SessionQuerySet(models.QuerySet):
def total_duration(self):
result = self.aggregate(
duration=Sum(F("duration_calculated") + F("duration_manual"))
)
return format_duration(result["duration"])
class Session(models.Model): class Session(models.Model):
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.duration_manual != None else "" mark = ", manual" if self.duration_manual != None else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_any()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
if self.duration_manual == None: manual = timedelta(0)
if self.timestamp_end == None or self.timestamp_start == None: calculated = timedelta(0)
return timedelta(0) if not self.duration_manual in (None, 0, timedelta(0)):
else: manual = self.duration_manual
value = self.timestamp_end - self.timestamp_start if self.timestamp_end != None and self.timestamp_start != None:
else: calculated = self.timestamp_end - self.timestamp_start
value = self.duration_manual return timedelta(seconds=(manual + calculated).total_seconds())
return timedelta(seconds=value.total_seconds())
def duration_formatted(self) -> str: def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%H:%m") result = format_duration(self.duration_seconds(), "%H:%m")
return result return result
def duration_any(self): @property
return ( def duration_sum(self) -> str:
self.duration_formatted() return Session.objects.all().total_duration()
if self.duration_manual == None
else self.duration_manual
)
@staticmethod
def calculated_sum() -> timedelta:
calculated_sum_query = Session.objects.all().aggregate(
Sum("duration_calculated")
)
calculated_sum = (
timedelta(0)
if calculated_sum_query["duration_calculated__sum"] == None
else calculated_sum_query["duration_calculated__sum"]
)
return calculated_sum
@staticmethod
def manual_sum() -> timedelta:
manual_sum_query = Session.objects.all().aggregate(Sum("duration_manual"))
manual_sum = (
timedelta(0)
if manual_sum_query["duration_manual__sum"] == None
else manual_sum_query["duration_manual__sum"]
)
return manual_sum
@staticmethod
def total_sum() -> timedelta:
return Session.manual_sum() + Session.calculated_sum()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None: if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else:
self.duration_calculated = timedelta(0)
super(Session, self).save(*args, **kwargs) super(Session, self).save(*args, **kwargs)

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
{% if purchase %} {% if purchase %}
<div class="text-center text-xl mb-4 dark:text-slate-400"> <div class="text-center text-xl mb-4 dark:text-slate-400">
<h1>Listing sessions only for purchase "{{ purchase }}"</h1> <h1>Listing sessions only for purchaseee "{{ purchase }}" (total playtime: {{ total_duration }})</h1>
<a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a> <a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a>
</div> </div>
{% endif %} {% endif %}

View File

@ -60,6 +60,7 @@ def list_sessions(request, purchase_id=None):
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset context["dataset"] = dataset
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@ -105,12 +106,6 @@ def add_platform(request):
def index(request): def index(request):
context = {} context = {}
if Session.objects.count() == 0: context["total_duration"] = Session().duration_sum
duration: str = ""
else:
context["total_duration"] = format_duration(
Session.total_sum(),
"%H hours %m minutes",
)
context["title"] = "Index" context["title"] = "Index"
return render(request, "index.html", context) return render(request, "index.html", context)

View File

@ -62,3 +62,7 @@ class FormatDurationTest(unittest.TestCase):
format_duration(None) format_duration(None)
except TypeError as exc: except TypeError as exc:
assert False, f"format_duration(None) raised an exception {exc}" assert False, f"format_duration(None) raised an exception {exc}"
def test_number(self):
self.assertEqual(format_duration(3600, "%H hour"), "1 hour")