15 Commits

Author SHA1 Message Date
465d958d9b Start sessions of last purchase from list
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #19
2023-01-13 16:54:24 +01:00
d8ece979a8 Revert make dev to plain runserver
The runserver_plus has problems with cache not being invalidated
2023-01-13 16:52:05 +01:00
2defdd4657 List number of sessions when filtering on session list
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-10 20:47:33 +01:00
078f87687f Make format_duration more robust
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-09 22:48:09 +01:00
49723831e9 Fix displaying finish button
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-09 22:05:12 +01:00
025ea0dd4e Fix migration
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-09 19:09:31 +01:00
97467c7a52 Also set duration_manual to zero
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-09 19:05:47 +01:00
7842d6f45d Remove debugging statement 2023-01-09 19:00:03 +01:00
b77089f7ad Show playtime total on session list
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #6
Fixes #25
2023-01-09 18:57:22 +01:00
24f4459318 Avoid raising exception on format_duration(None)
Fixes #25
2023-01-09 16:14:01 +01:00
751182df52 Emit gunicorn logs to stdin and stderr 2023-01-08 15:48:53 +01:00
33e136a810 Add .dockerignore 2023-01-08 15:48:31 +01:00
362732c22a Run make date via poetry 2023-01-08 15:48:12 +01:00
8e1c670ffd Fix collectstaticfiles causing error
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #23
2023-01-08 15:46:09 +01:00
e5a9b9aa50 Fix CSRF error (#22)
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #21

Reviewed-on: #22
2023-01-08 14:35:28 +00:00
16 changed files with 239 additions and 72 deletions

View File

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

View File

@ -1,3 +1,21 @@
## 0.2.1 / 2023-01-13 16:53+01:00
* List number of sessions when filtering on session list
* Start sessions of last purchase from list (https://git.kucharczyk.xyz/lukas/timetracker/issues/19)
## 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
* Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22)
## 0.1.2 / 2023-01-07 22:05+01:00 ## 0.1.2 / 2023-01-07 22:05+01:00
* Switch to Uvicorn/Gunicorn + Caddy (https://git.kucharczyk.xyz/lukas/timetracker/pulls/4) * Switch to Uvicorn/Gunicorn + Caddy (https://git.kucharczyk.xyz/lukas/timetracker/pulls/4)
@ -23,4 +41,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.1.2 ENV VERSION_NUMBER 0.2.0-2-gd8ece97
ENV PROD 1 ENV PROD 1
RUN apk add \ RUN apk add \

View File

@ -20,7 +20,7 @@ migrate: makemigrations
poetry run python src/web/manage.py migrate poetry run python src/web/manage.py migrate
dev: migrate sethookdir dev: migrate sethookdir
poetry run python src/web/manage.py runserver_plus poetry run python src/web/manage.py runserver
caddy: caddy:
caddy run --watch caddy run --watch
@ -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 poetry run python src/web/manage.py collectstatic --clear --no-input
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:
python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' poetry run python -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 poetry run python src/web/manage.py collectstatic --clear --no-input
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 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "0.1.2" version = "0.2.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,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, format_string: str = "%H hours %m minutes" duration: timedelta | int | None, format_string: str = "%H hours"
) -> str: ) -> str:
""" """
Format timedelta into the specified format_string. Format timedelta into the specified format_string.
@ -18,19 +27,28 @@ 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
if not isinstance(duration, timedelta): duration = _safe_timedelta(duration)
duration = timedelta(seconds=duration) # we don't need float
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, remainder = divmod(seconds_total, day_seconds) days = hours = minutes = seconds = 0
hours, remainder = divmod(remainder, hour_seconds) remainder = seconds = seconds_total
minutes, seconds = divmod(remainder, minute_seconds) 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 = { literals = {
"%d": str(days), "%d": str(days),
"%H": str(hours), "%H": str(hours),

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

@ -0,0 +1,34 @@
# 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,9 @@ 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
from django.db.models import Manager
from typing import Any
class Game(models.Model): class Game(models.Model):
@ -32,69 +34,58 @@ 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 start_now():
self.timestamp_start = 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 @property
def calculated_sum() -> timedelta: def last(self) -> Manager[Any]:
calculated_sum_query = Session.objects.all().aggregate( return Session.objects.all().order_by("timestamp_start")[:-1]
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

@ -3,12 +3,18 @@
{% block title %}Sessions{% endblock title %} {% block title %}Sessions{% endblock title %}
{% block content %} {% block content %}
{% 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">
<a href="{% url 'start_session' dataset.last.purchase.id %}">
<button type="button" title="Track last tracked" class="py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-12 h-6 rounded-lg ">
New session of {{ dataset.last.purchase }}
</button>
</a>
{% if purchase %}
<h1>Listing sessions only for purchase "{{ purchase }}"</h1> <h1>Listing sessions only for purchase "{{ purchase }}"</h1>
<h2>Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</h2>
<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>
{% endif %}
</div> </div>
{% endif %}
<div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center"> <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
<div class="dark:border-white dark:text-slate-300 text-lg">Name</div> <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
<div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div> <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>

View File

@ -12,6 +12,11 @@ urlpatterns = [
views.update_session, views.update_session,
name="update_session", name="update_session",
), ),
path(
"start-session/<int:purchase_id>",
views.start_session,
name="start_session",
),
path( path(
"delete_session/by-id/<int:session_id>", "delete_session/by-id/<int:session_id>",
views.delete_session, views.delete_session,

View File

@ -40,6 +40,12 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def start_session(request, purchase_id=None):
session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()})
session.save()
return redirect("list_sessions")
def delete_session(request, session_id=None): def delete_session(request, session_id=None):
session = Session.objects.get(id=session_id) session = Session.objects.get(id=session_id)
session.delete() session.delete()
@ -56,10 +62,11 @@ 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 == None: if session.timestamp_end == None and session.duration_manual.seconds == 0:
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 +112,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

@ -145,7 +145,8 @@ LOGGING = {
}, },
} }
CSRF_TRUSTED_ORIGINS = [] _csrf_trusted_origins = os.environ.get("CSRF_TRUSTED_ORIGINS")
if _csrf_trusted_origins:
if os.environ.get("PROD"): CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
CSRF_TRUSTED_ORIGINS.append(os.environ.get("CSRF_TRUSTED_ORIGINS")) else:
CSRF_TRUSTED_ORIGINS = []

View File

@ -18,15 +18,40 @@ 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, "1 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")
def test_only_rawseconds(self): def test_only_rawseconds(self):
delta = timedelta(seconds=5690) delta = timedelta(seconds=5690)
@ -56,3 +81,12 @@ 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")