2 Commits
0.2.0 ... 0.1.3

Author SHA1 Message Date
e4075151f3 Update version, changelog 2023-01-08 15:30:28 +01:00
d3682368b4 Fix CSRF error
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-08 15:23:04 +01:00
14 changed files with 67 additions and 198 deletions

View File

@ -1,5 +1 @@
src/web/static/* src/web/static/*
.venv
.githooks
.vscode
node_modules

View File

@ -1,12 +1,3 @@
## 0.2.0 / 2023-01-09 22:42+01:00
* Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6)
* Make formatting durations more robust, change default duration display to "X hours" (https://git.kucharczyk.xyz/lukas/timetracker/issues/26)
## 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)
## 0.1.3 / 2023-01-08 15:23+01:00 ## 0.1.3 / 2023-01-08 15:23+01:00
* Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22) * Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22)
@ -36,4 +27,4 @@
* Make it possible to add a new platform * Make it possible to add a new platform
* Save calculated duration to database if both timestamps are set * Save calculated duration to database if both timestamps are set
* Improve session listing * Improve session listing
* Set version in the footer to fixed, fix main container height * Set version in the footer to fixed, fix main container height

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.2.0 ENV VERSION_NUMBER 0.1.0-60-gd368236
ENV PROD 1 ENV PROD 1
RUN apk add \ RUN apk add \

View File

@ -44,7 +44,7 @@ shell:
poetry run python src/web/manage.py shell poetry run python src/web/manage.py shell
collectstatic: collectstatic:
poetry run python src/web/manage.py collectstatic --clear --no-input poetry run python src/web/manage.py collectstatic
poetry.lock: pyproject.toml poetry.lock: pyproject.toml
poetry install poetry install
@ -56,7 +56,7 @@ sethookdir:
git config core.hooksPath .githooks git config core.hooksPath .githooks
date: date:
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
cleanstatic: cleanstatic:
rm -r src/web/static/* rm -r src/web/static/*

View File

@ -5,9 +5,9 @@ echo "Apply database migrations"
poetry run python src/web/manage.py migrate poetry run python src/web/manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python src/web/manage.py collectstatic --clear --no-input poetry run python src/web/manage.py collectstatic
echo "Starting server" echo "Starting server"
caddy start caddy start
cd src/web || exit cd src/web || exit
poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker

View File

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

View File

@ -8,17 +8,8 @@ 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 | int | None, format_string: str = "%H hours" duration: timedelta, format_string: str = "%H hours %m minutes"
) -> str: ) -> str:
""" """
Format timedelta into the specified format_string. Format timedelta into the specified format_string.
@ -27,28 +18,19 @@ def format_duration(
- %m minutes - %m minutes
- %s seconds - %s seconds
- %r total seconds - %r total seconds
Values don't change into higher units if those units are missing
from the formatting string. For example:
- 61 seconds as "%s" = 61 seconds
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
""" """
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
duration = _safe_timedelta(duration) if not isinstance(duration, timedelta):
# we don't need float 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:
seconds_total = 0 seconds_total = 0
days = hours = minutes = seconds = 0 days, remainder = divmod(seconds_total, day_seconds)
remainder = seconds = seconds_total hours, remainder = divmod(remainder, hour_seconds)
if "%d" in format_string: minutes, seconds = divmod(remainder, minute_seconds)
days, remainder = divmod(seconds_total, day_seconds)
if "%H" in format_string:
hours, remainder = divmod(remainder, hour_seconds)
if "%m" in format_string:
minutes, seconds = divmod(remainder, minute_seconds)
literals = { literals = {
"%d": str(days), "%d": str(days),
"%H": str(hours), "%H": str(hours),

View File

@ -1,21 +0,0 @@
# 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

@ -1,34 +0,0 @@
# 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

@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 18:04
from django.db import migrations
from datetime import timedelta
def set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session")
for session in Session.objects.all():
if session.duration_manual == None:
session.duration_manual = timedelta(0)
session.save()
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session")
for session in Session.objects.all():
if session.duration_manual == timedelta(0):
session.duration_manual = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("tracker", "0005_auto_20230109_1843"),
]
operations = [
migrations.RunPython(
set_duration_manual_none_to_zero,
revert_set_duration_manual_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, F from django.db.models import Sum
class Game(models.Model): class Game(models.Model):
@ -32,51 +32,69 @@ 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, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True)
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_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_any()}{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:
manual = timedelta(0) if self.duration_manual == None:
calculated = timedelta(0) if self.timestamp_end == None or self.timestamp_start == None:
if not self.duration_manual in (None, 0, timedelta(0)): return timedelta(0)
manual = self.duration_manual else:
if self.timestamp_end != None and self.timestamp_start != None: value = self.timestamp_end - self.timestamp_start
calculated = self.timestamp_end - self.timestamp_start else:
return timedelta(seconds=(manual + calculated).total_seconds()) value = self.duration_manual
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
@property def duration_any(self):
def duration_sum(self) -> str: return (
return Session.objects.all().total_duration() 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()
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 }}" (total playtime: {{ total_duration }})</h1> <h1>Listing sessions only for purchase "{{ purchase }}"</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

@ -56,11 +56,10 @@ def list_sessions(request, purchase_id=None):
dataset = Session.objects.all().order_by("timestamp_start") dataset = Session.objects.all().order_by("timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual.seconds == 0: if session.timestamp_end == None and session.duration_manual == 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)
@ -106,6 +105,12 @@ def add_platform(request):
def index(request): def index(request):
context = {} context = {}
context["total_duration"] = Session().duration_sum if Session.objects.count() == 0:
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

@ -18,40 +18,15 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
self.assertEqual(result, "1 hours") self.assertEqual(result, "1 hours")
def test_overflow_hours(self):
delta = timedelta(hours=25)
result = format_duration(delta, "%H hours")
self.assertEqual(result, "25 hours")
def test_overflow_hours_into_days(self):
delta = timedelta(hours=25)
result = format_duration(delta, "%d days, %H hours")
self.assertEqual(result, "1 days, 1 hours")
def test_only_minutes(self): def test_only_minutes(self):
delta = timedelta(minutes=34) delta = timedelta(minutes=34)
result = format_duration(delta, "%m minutes") result = format_duration(delta, "%m minutes")
self.assertEqual(result, "34 minutes") self.assertEqual(result, "34 minutes")
def test_only_overflow_minutes(self):
delta = timedelta(minutes=61)
result = format_duration(delta, "%m minutes")
self.assertEqual(result, "61 minutes")
def test_overflow_minutes_into_hours(self):
delta = timedelta(minutes=61)
result = format_duration(delta, "%H hours, %m minutes")
self.assertEqual(result, "1 hours, 1 minutes")
def test_only_overflow_seconds(self): def test_only_overflow_seconds(self):
delta = timedelta(seconds=61) delta = timedelta(seconds=61)
result = format_duration(delta, "%s seconds") result = format_duration(delta, "%s seconds")
self.assertEqual(result, "61 seconds") self.assertEqual(result, "1 seconds")
def test_overflow_seconds_into_minutes(self):
delta = timedelta(seconds=61)
result = format_duration(delta, "%m minutes, %s seconds")
self.assertEqual(result, "1 minutes, 1 seconds")
def test_only_rawseconds(self): def test_only_rawseconds(self):
delta = timedelta(seconds=5690) delta = timedelta(seconds=5690)
@ -81,12 +56,3 @@ class FormatDurationTest(unittest.TestCase):
delta = timedelta(hours=-2) delta = timedelta(hours=-2)
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
self.assertEqual(result, "0 hours") self.assertEqual(result, "0 hours")
def test_none(self):
try:
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")