3 Commits

Author SHA1 Message Date
615d4e59c6 Fix collectstaticfiles causing error when restarting container
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #23
2023-01-08 15:38:56 +01:00
83f075e49d Update version, changelog
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-08 15:31:35 +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 194 deletions

View File

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

View File

@ -1,9 +1,4 @@
## 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
## Unreleased
* 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
ENV VERSION_NUMBER 0.2.0
ENV VERSION_NUMBER 0.1.2-3-g83f075e
ENV PROD 1
RUN apk add \

View File

@ -44,7 +44,7 @@ shell:
poetry run python src/web/manage.py shell
collectstatic:
poetry run python src/web/manage.py collectstatic --clear --no-input
poetry run python src/web/manage.py collectstatic -c --no-input
poetry.lock: pyproject.toml
poetry install
@ -56,7 +56,7 @@ sethookdir:
git config core.hooksPath .githooks
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:
rm -r src/web/static/*

View File

@ -10,4 +10,4 @@ poetry run python src/web/manage.py collectstatic --clear --no-input
echo "Starting server"
caddy start
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]
name = "timetracker"
version = "0.2.0"
version = "0.1.2"
description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL"

View File

@ -8,17 +8,8 @@ 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 | int | None, format_string: str = "%H hours"
duration: timedelta, format_string: str = "%H hours %m minutes"
) -> str:
"""
Format timedelta into the specified format_string.
@ -27,27 +18,18 @@ def format_duration(
- %m minutes
- %s 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
hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds
duration = _safe_timedelta(duration)
# we don't need float
if not isinstance(duration, timedelta):
duration = timedelta(seconds=duration)
seconds_total = int(duration.total_seconds())
# timestamps where end is before start
if seconds_total < 0:
seconds_total = 0
days = hours = minutes = seconds = 0
remainder = seconds = seconds_total
if "%d" in format_string:
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 = {
"%d": str(days),

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 zoneinfo import ZoneInfo
from common.util.time import format_duration
from django.db.models import Sum, F
from django.db.models import Sum
class Game(models.Model):
@ -32,51 +32,69 @@ 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, default=timedelta(0))
duration_manual = models.DurationField(blank=True, null=True)
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_formatted()}{mark})"
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_any()}{mark})"
def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self) -> timedelta:
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())
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())
def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%H:%m")
return result
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration()
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()
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)

View File

@ -5,7 +5,7 @@
{% block content %}
{% if purchase %}
<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>
</div>
{% endif %}

View File

@ -56,11 +56,10 @@ def list_sessions(request, purchase_id=None):
dataset = Session.objects.all().order_by("timestamp_start")
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.unfinished = True
context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset
return render(request, "list_sessions.html", context)
@ -106,6 +105,12 @@ def add_platform(request):
def index(request):
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"
return render(request, "index.html", context)

View File

@ -18,40 +18,15 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%H 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):
delta = timedelta(minutes=34)
result = format_duration(delta, "%m 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):
delta = timedelta(seconds=61)
result = format_duration(delta, "%s seconds")
self.assertEqual(result, "61 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")
self.assertEqual(result, "1 seconds")
def test_only_rawseconds(self):
delta = timedelta(seconds=5690)
@ -81,12 +56,3 @@ class FormatDurationTest(unittest.TestCase):
delta = timedelta(hours=-2)
result = format_duration(delta, "%H 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")