14 Commits

Author SHA1 Message Date
7dfd91421e Try fixing the problem
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-01-16 23:23:00 +01:00
4d91a76513 Add date and time pickers to forms
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-16 22:07:43 +01:00
e51d586255 Automatically select purchase when adding session 2023-01-16 21:19:20 +01:00
2553d6f9e6 Definitively disable pre-commit hook
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-16 19:49:41 +01:00
8cf6270d8f Bump version 2023-01-16 19:47:32 +01:00
0b1089b0f4 Disable pre-commit hook 2023-01-16 19:46:15 +01:00
9534492f17 Exclude manual times from graphs
Fixes #35
2023-01-16 19:39:24 +01:00
8b7ed90b49 Improve playtime graph date formatting 2023-01-16 19:27:52 +01:00
2ce4dd3a0e Fix graph timeline being backwards 2023-01-16 17:26:10 +01:00
a851b5329a Correctly display game that is used as filter 2023-01-16 17:24:34 +01:00
6fa049e1b1 Sort and clean up imports 2023-01-15 23:39:52 +01:00
6b7ed0dbb5 Order by timestamp_start from the newest
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-15 23:20:43 +01:00
dd50d6dd40 Allow filtering by platform and game
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #32
2023-01-15 23:14:28 +01:00
162f4f3dbf Fix Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-15 19:16:34 +01:00
25 changed files with 268 additions and 85 deletions

0
.githooks/pre-commit → .githooks/pre-commit.disabled Executable file → Normal file
View File

View File

@ -1,3 +1,22 @@
## Unreleased
* New
* When adding session, pre-select game with the last session
* Date and time input fields now have proper pickers
## 0.2.4 / 2023-01-16 19:39+01:00
* Fixed
* When filtering by game, the "Filtering by (...)" text would erroneously list an unrelated platform
* Playtime graph would display timeline backwards
* Playtime graph with many dates would overlap (https://git.kucharczyk.xyz/lukas/timetracker/issues/34)
* Manually added times (= without end timestamp) would make graphs look ugly and noisy (https://git.kucharczyk.xyz/lukas/timetracker/issues/35)
## 0.2.3 / 2023-01-15 23:13+01:00
* Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32)
* Order session by newest as preparation for https://git.kucharczyk.xyz/lukas/timetracker/issues/33
## 0.2.2 / 2023-01-15 17:59+01:00
* Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29)

View File

@ -4,17 +4,25 @@ COPY . /app
RUN npm install && \
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify
FROM python:3.10.9-alpine
FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 0.2.2
ENV VERSION_NUMBER 0.2.4
ENV PROD 1
ENV PYTHONUNBUFFERED=1
RUN apk add \
RUN apt update && \
apt install -y \
bash \
vim \
curl \
caddy
RUN adduser -D -u 1000 timetracker
curl && \
apt install -y debian-keyring debian-archive-keyring apt-transport-https && \
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && \
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list && \
apt update && \
apt install caddy && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app

View File

@ -19,13 +19,13 @@ makemigrations:
migrate: makemigrations
poetry run python src/web/manage.py migrate
dev: migrate sethookdir
dev: migrate
poetry run python src/web/manage.py runserver
caddy:
caddy run --watch
dev-prod: migrate collectstatic sethookdir
dev-prod: migrate collectstatic
cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
dumptracker:
@ -52,9 +52,6 @@ poetry.lock: pyproject.toml
test: poetry.lock
poetry run pytest
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=" "))'

20
poetry.lock generated
View File

@ -417,6 +417,24 @@ files = [
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
[[package]]
name = "isort"
version = "5.11.4"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "isort-5.11.4-py3-none-any.whl", hash = "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b"},
{file = "isort-5.11.4.tar.gz", hash = "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6"},
]
[package.extras]
colors = ["colorama (>=0.4.3,<0.5.0)"]
pipfile-deprecated-finder = ["pipreqs", "requirementslib"]
plugins = ["setuptools"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
[[package]]
name = "jsbeautifier"
version = "1.14.7"
@ -1279,4 +1297,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "01d5c9b89b638c993f8540298dedfa79321b3aac1b2af70da58ef77706d0a113"
content-hash = "bf02e951b8c14fbe6f2709b6df7f9664f99ad5fbf7195d8e7b18c8574d00e683"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "timetracker"
version = "0.2.2"
version = "0.2.4"
description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL"
@ -23,6 +23,7 @@ django-extensions = "^3.2.1"
werkzeug = "^2.2.2"
djhtml = "^1.5.2"
djlint = "^1.19.11"
isort = "^5.11.4"
[build-system]
requires = ["poetry-core"]

View File

@ -14,7 +14,7 @@ textarea {
#session-table {
display: grid;
grid-template-columns: repeat(3, 2fr) 0.5fr 1fr;
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {

View File

@ -1,14 +1,13 @@
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import date2num
import base64
from io import BytesIO
from tracker.models import Session
from django.db.models import Sum, IntegerField, F
from django.db.models.functions import TruncDay
import logging
from datetime import datetime
from django.db.models import QuerySet
from io import BytesIO
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from django.db.models import F, IntegerField, QuerySet, Sum
from django.db.models.functions import TruncDay
from tracker.models import Session
def key_value_to_value_value(data):
@ -18,11 +17,12 @@ def key_value_to_value_value(data):
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000
result = (
queryset.annotate(date=TruncDay("timestamp_start"))
queryset.exclude(timestamp_end__exact=None)
.annotate(date=TruncDay("timestamp_start"))
.values("date")
.annotate(
hours=Sum(
F("duration_calculated") + F("duration_manual"),
F("duration_calculated"),
output_field=IntegerField(),
)
)
@ -32,12 +32,18 @@ def playtime_over_time_chart(queryset: QuerySet = Session.objects):
values = []
running_total = int(0)
for item in result:
date_value = datetime.strftime(item["date"], "%d-%m-%Y")
# date_value = datetime.strftime(item["date"], "%d-%m-%Y")
date_value = item["date"]
keys.append(date_value)
running_total += int(item["hours"] / (3600 * microsecond_in_second))
values.append(running_total)
data = [keys, values]
return get_chart(data, title="Playtime over time", xlabel="Date", ylabel="Hours")
return get_chart(
data,
title="Playtime over time (manual excluded)",
xlabel="Date",
ylabel="Hours",
)
def get_graph():
@ -52,13 +58,34 @@ def get_graph():
def get_chart(data, title="", xlabel="", ylabel=""):
x = data[0]
y = data[1]
plt.style.use("dark_background")
plt.switch_backend("SVG")
fig = plt.figure(figsize=(10, 4))
plt.plot(data[0], data[1])
plt.title(title)
plt.xlabel(xlabel)
plt.ylabel(ylabel)
plt.tight_layout()
fig, ax = plt.subplots()
fig.set_size_inches(10, 4)
ax.plot(x, y)
first = x[0]
last = x[-1]
difference = last - first
if difference.days <= 14:
ax.xaxis.set_major_locator(mdates.DayLocator())
elif difference.days < 60 or len(x) < 60:
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
ax.xaxis.set_minor_locator(mdates.DayLocator())
elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
else:
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
for label in ax.get_xticklabels(which="major"):
label.set(rotation=30, horizontalalignment="right")
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
fig.tight_layout()
chart = get_graph()
return chart

View File

@ -1,7 +1,8 @@
from datetime import datetime, timedelta
from django.conf import settings
from zoneinfo import ZoneInfo
import re
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:

View File

@ -1,5 +1,6 @@
from django.contrib import admin
from .models import Game, Purchase, Platform, Session
from .models import Game, Platform, Purchase, Session
# Register your models here.
admin.site.register(Game)

View File

@ -1,8 +1,26 @@
from django import forms
from .models import Session, Purchase, Game, Platform
from .models import Game, Platform, Purchase, Session
class SessionForm(forms.ModelForm):
custom_datetime_widget = forms.SplitDateTimeWidget(
date_format=("%d-%m-%Y"),
time_format=("%H:%M"),
date_attrs={"type": "date"},
time_attrs={"type": "time"},
)
timestamp_start = forms.SplitDateTimeField(
input_date_formats="['%d-%m-%Y]",
input_time_formats="['%H:%M']",
widget=custom_datetime_widget,
)
timestamp_end = forms.SplitDateTimeField(
input_date_formats="['%d-%m-%Y]",
input_time_formats="['%H:%M']",
widget=custom_datetime_widget,
)
class Meta:
model = Session
fields = [
@ -13,11 +31,27 @@ class SessionForm(forms.ModelForm):
"note",
]
# fields_classes = {
# "timestamp_start": custom_datetime_field,
# "timestamp_end": custom_datetime_field,
# }
# widgets = {
# "timestamp_start": custom_datetime_widget,
# "timestamp_end": custom_datetime_widget,
# }
class PurchaseForm(forms.ModelForm):
class Meta:
model = Purchase
fields = ["game", "platform", "date_purchased", "date_refunded"]
custom_date_widget = forms.DateInput(
format=("%d-%m-%Y"), attrs={"type": "date"}
)
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
}
class GameForm(forms.ModelForm):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.4 on 2023-01-02 18:27
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,6 +1,7 @@
# Generated by Django 4.1.4 on 2023-01-02 18:55
import datetime
from django.db import migrations, models

View File

@ -1,6 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-09 14:49
import datetime
from django.db import migrations, models

View File

@ -1,8 +1,9 @@
# Generated by Django 4.1.5 on 2023-01-09 17:43
from django.db import migrations
from datetime import timedelta
from django.db import migrations
def set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session")

View File

@ -1,8 +1,9 @@
# Generated by Django 4.1.5 on 2023-01-09 18:04
from django.db import migrations
from datetime import timedelta
from django.db import migrations
def set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session")

View File

@ -1,11 +1,11 @@
from django.db import models
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 Manager
from typing import Any
from zoneinfo import ZoneInfo
from common.util.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
class Game(models.Model):
@ -81,7 +81,7 @@ class Session(models.Model):
@property
def last(self) -> Manager[Any]:
return Session.objects.all().order_by("timestamp_start")[:-1]
return Session.objects.all().order_by("timestamp_start")[0]
def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None:

View File

@ -786,6 +786,11 @@ select {
margin-right: auto;
}
.my-5 {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
.mb-4 {
margin-bottom: 1rem;
}
@ -794,14 +799,26 @@ select {
margin-top: 1rem;
}
.mb-5 {
margin-bottom: 1.25rem;
.mb-3 {
margin-bottom: 0.75rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.mt-5 {
margin-top: 1.25rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mt-10 {
margin-top: 2.5rem;
}
.block {
display: block;
}
@ -842,6 +859,10 @@ select {
display: none !important;
}
.h-6 {
height: 1.5rem;
}
.h-5 {
height: 1.25rem;
}
@ -858,6 +879,10 @@ select {
width: 100%;
}
.w-6 {
width: 1.5rem;
}
.w-5 {
width: 1.25rem;
}
@ -910,12 +935,20 @@ select {
align-self: center;
}
.overflow-hidden {
overflow: hidden;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis {
text-overflow: ellipsis;
}
.whitespace-nowrap {
white-space: nowrap;
}
@ -1052,6 +1085,11 @@ select {
color: rgb(248 113 113 / var(--tw-text-opacity));
}
.text-red-700 {
--tw-text-opacity: 1;
color: rgb(185 28 28 / var(--tw-text-opacity));
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
@ -1122,7 +1160,7 @@ textarea {
#session-table {
display: grid;
grid-template-columns: repeat(3, 2fr) 0.5fr 1fr;
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {

View File

@ -5,29 +5,39 @@
{% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400">
{% if dataset.count >= 2 %}
<img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-5" />
<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 rounded-lg ">
New session of {{ dataset.last.purchase }}
<img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" />
{% endif %}
{% if dataset.count >= 1 %}
<div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
{% endif %}
{% if purchase or platform or game %}
<a class="text-red-400 inline" href="{% url 'list_sessions' %}"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a><span>Filtering by "{% firstof purchase platform game %}"</span>
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %}
{% endif %}
{% if dataset.count >= 1 %}
<a class="block" href="{% url 'start_session' dataset.last.purchase.id %}">
<button type="button" title="Track last tracked" class="mt-10 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 rounded-lg ">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
{{ last.purchase }}
</button>
</a>
{% else %}
Playtime chart will be displayed when there are 2 or more sessions.
{% endif %}
{% if purchase %}
<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>
{% endif %}
</div>
<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">Platform</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">End</div>
<div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
<div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
{% for data in dataset %}
<div><a class="dark:text-white hover:underline" href="{% url 'list_sessions' data.purchase.id %}">{{ data.purchase }}</a></div>
<div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_purchase' data.purchase.id %}">{{ data.purchase.game }}</a></div>
<div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">{{ data.purchase.platform }}</a></div>
<div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
<div class="dark:text-slate-400 text-center">
{% if data.unfinished %}

View File

@ -1,7 +1,8 @@
import os
import time
from django import template
from django.conf import settings
import time
import os
register = template.Library()

View File

@ -27,6 +27,19 @@ urlpatterns = [
path(
"list-sessions/by-purchase/<int:purchase_id>",
views.list_sessions,
name="list_sessions",
{"filter": "purchase"},
name="list_sessions_by_purchase",
),
path(
"list-sessions/by-platform/<int:platform_id>",
views.list_sessions,
{"filter": "platform"},
name="list_sessions_by_platform",
),
path(
"list-sessions/by-game/<int:game_id>",
views.list_sessions,
{"filter": "game"},
name="list_sessions_by_game",
),
]

View File

@ -1,14 +1,13 @@
from django.shortcuts import render, redirect
from .models import Game, Platform, Purchase, Session
from .forms import SessionForm, PurchaseForm, GameForm, PlatformForm
from datetime import datetime, timedelta
from datetime import datetime
from zoneinfo import ZoneInfo
from django.conf import settings
from common.util.time import now as now_with_tz, format_duration
from django.db.models import Sum
import logging
from common.util.plots import playtime_over_time_chart
from common.util.time import now as now_with_tz
from django.conf import settings
from django.shortcuts import redirect, render
from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm
from .models import Game, Platform, Purchase, Session
def model_counts(request):
@ -23,7 +22,12 @@ def model_counts(request):
def add_session(request):
context = {}
now = now_with_tz()
initial = {"timestamp_start": now}
last = Session.objects.all().last()
initial = {
"timestamp_start_0": now.date(),
"timestamp_start_1": now.time(),
"purchase": last.purchase,
}
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
@ -53,14 +57,20 @@ def delete_session(request, session_id=None):
return redirect("list_sessions")
def list_sessions(request, purchase_id=None):
def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""):
context = {}
if purchase_id != None:
if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "game":
dataset = Session.objects.filter(purchase__game=game_id)
context["game"] = Game.objects.get(id=game_id)
else:
dataset = Session.objects.all().order_by("timestamp_start")
dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset:
if session.timestamp_end == None and session.duration_manual.seconds == 0:
@ -69,7 +79,9 @@ def list_sessions(request, purchase_id=None):
context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset
context["chart"] = playtime_over_time_chart(dataset)
context["last"] = Session.objects.all().last()
# charts are always oldest->newest
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context)

View File

@ -10,9 +10,8 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.1/ref/settings/
"""
from pathlib import Path
import logging
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

View File

@ -13,11 +13,10 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
from django.conf import settings
urlpatterns = [
path("", RedirectView.as_view(url="/tracker/list-sessions")),

View File

@ -1,7 +1,8 @@
import unittest
from web.common.util.time import format_duration
from datetime import timedelta
from web.common.util.time import format_duration
class FormatDurationTest(unittest.TestCase):
def setUp(self) -> None: