From b77089f7ad965dbdd159a3f8cb4cd771531838a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Mon, 9 Jan 2023 18:57:22 +0100 Subject: [PATCH] Show playtime total on session list Fixes #6 Fixes #25 --- CHANGELOG.md | 4 ++ Dockerfile | 2 +- src/web/common/util/time.py | 18 +++-- .../0004_alter_session_duration_manual.py | 21 ++++++ .../migrations/0005_auto_20230109_1843.py | 34 ++++++++++ src/web/tracker/models.py | 68 +++++++------------ src/web/tracker/templates/list_sessions.html | 2 +- src/web/tracker/views.py | 9 +-- tests/test_time.py | 4 ++ 9 files changed, 104 insertions(+), 58 deletions(-) create mode 100644 src/web/tracker/migrations/0004_alter_session_duration_manual.py create mode 100644 src/web/tracker/migrations/0005_auto_20230109_1843.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9412717..f5774c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 * Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23) diff --git a/Dockerfile b/Dockerfile index edb576c..2172c54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN npm install && \ 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 RUN apk add \ diff --git a/src/web/common/util/time.py b/src/web/common/util/time.py index 38a9aa3..084ab38 100644 --- a/src/web/common/util/time.py +++ b/src/web/common/util/time.py @@ -8,8 +8,17 @@ def now() -> datetime: 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( - duration: timedelta | None, format_string: str = "%H hours %m minutes" + duration: timedelta | int | None, format_string: str = "%H hours %m minutes" ) -> str: """ Format timedelta into the specified format_string. @@ -22,11 +31,8 @@ def format_duration( minute_seconds = 60 hour_seconds = 60 * minute_seconds day_seconds = 24 * hour_seconds - if not isinstance(duration, timedelta): - if duration == None: - duration = timedelta(seconds=0) - else: - duration = timedelta(seconds=duration) + duration = _safe_timedelta(duration) + # we don't need float seconds_total = int(duration.total_seconds()) # timestamps where end is before start if seconds_total < 0: diff --git a/src/web/tracker/migrations/0004_alter_session_duration_manual.py b/src/web/tracker/migrations/0004_alter_session_duration_manual.py new file mode 100644 index 0000000..d6e1cad --- /dev/null +++ b/src/web/tracker/migrations/0004_alter_session_duration_manual.py @@ -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 + ), + ), + ] diff --git a/src/web/tracker/migrations/0005_auto_20230109_1843.py b/src/web/tracker/migrations/0005_auto_20230109_1843.py new file mode 100644 index 0000000..ab1988f --- /dev/null +++ b/src/web/tracker/migrations/0005_auto_20230109_1843.py @@ -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, + ) + ] diff --git a/src/web/tracker/models.py b/src/web/tracker/models.py index 3cc1363..66095d7 100644 --- a/src/web/tracker/models.py +++ b/src/web/tracker/models.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from django.conf import settings from zoneinfo import ZoneInfo from common.util.time import format_duration -from django.db.models import Sum +from django.db.models import Sum, F class Game(models.Model): @@ -32,69 +32,51 @@ class Platform(models.Model): 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): purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) timestamp_start = models.DateTimeField() 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) note = models.TextField(blank=True, null=True) + objects = SessionQuerySet.as_manager() + def __str__(self): 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): self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) def duration_seconds(self) -> timedelta: - if self.duration_manual == None: - if self.timestamp_end == None or self.timestamp_start == None: - return timedelta(0) - else: - value = self.timestamp_end - self.timestamp_start - else: - value = self.duration_manual - return timedelta(seconds=value.total_seconds()) + manual = timedelta(0) + calculated = timedelta(0) + if not self.duration_manual in (None, 0, timedelta(0)): + manual = self.duration_manual + if self.timestamp_end != None and self.timestamp_start != None: + calculated = self.timestamp_end - self.timestamp_start + return timedelta(seconds=(manual + calculated).total_seconds()) def duration_formatted(self) -> str: result = format_duration(self.duration_seconds(), "%H:%m") return result - def duration_any(self): - return ( - self.duration_formatted() - 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() + @property + def duration_sum(self) -> str: + return Session.objects.all().total_duration() def save(self, *args, **kwargs): if self.timestamp_start != None and self.timestamp_end != None: self.duration_calculated = self.timestamp_end - self.timestamp_start + else: + self.duration_calculated = timedelta(0) super(Session, self).save(*args, **kwargs) diff --git a/src/web/tracker/templates/list_sessions.html b/src/web/tracker/templates/list_sessions.html index 7796c7f..6e74a67 100644 --- a/src/web/tracker/templates/list_sessions.html +++ b/src/web/tracker/templates/list_sessions.html @@ -5,7 +5,7 @@ {% block content %} {% if purchase %}
-

Listing sessions only for purchase "{{ purchase }}"

+

Listing sessions only for purchaseee "{{ purchase }}" (total playtime: {{ total_duration }})

View all sessions
{% endif %} diff --git a/src/web/tracker/views.py b/src/web/tracker/views.py index 65d0bc9..8418537 100644 --- a/src/web/tracker/views.py +++ b/src/web/tracker/views.py @@ -60,6 +60,7 @@ def list_sessions(request, purchase_id=None): session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) session.unfinished = True + context["total_duration"] = dataset.total_duration() context["dataset"] = dataset return render(request, "list_sessions.html", context) @@ -105,12 +106,6 @@ def add_platform(request): def index(request): context = {} - if Session.objects.count() == 0: - duration: str = "" - else: - context["total_duration"] = format_duration( - Session.total_sum(), - "%H hours %m minutes", - ) + context["total_duration"] = Session().duration_sum context["title"] = "Index" return render(request, "index.html", context) diff --git a/tests/test_time.py b/tests/test_time.py index d85e077..f7ca4ce 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -62,3 +62,7 @@ class FormatDurationTest(unittest.TestCase): format_duration(None) except TypeError as exc: assert False, f"format_duration(None) raised an exception {exc}" + + def test_number(self): + self.assertEqual(format_duration(3600, "%H hour"), "1 hour") +