4 Commits

Author SHA1 Message Date
d9d4a3eaa1 Add more sample data
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-19 20:27:45 +01:00
8a1f66bfaf Fix entrypoint.sh 2023-01-19 20:20:50 +01:00
0224afcad9 Don't freak out if there are no sessions 2023-01-19 19:51:24 +01:00
a694406e99 Clean up naming 2023-01-19 19:46:43 +01:00
69 changed files with 1431 additions and 6351 deletions

View File

@ -11,30 +11,15 @@ steps:
- poetry install - poetry install
- poetry env info - poetry env info
- poetry run pytest - poetry run pytest
- name: build container (prod) - name: build container
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: tags:
- latest - latest
when:
branch:
- main
- name: build container (non-prod)
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when:
branch:
exclude:
- main
trigger: trigger:
event: event:
- push - push
- cron - cron
exclude:
- pull_request

4
.gitignore vendored
View File

@ -5,6 +5,4 @@ __pycache__
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
static/admin/ src/timetracker/static
static/django_extensions/
dist/

View File

@ -1,31 +1,3 @@
## 1.0.3 / 2023-02-20 17:16+01:00
* Add wikidata ID and year for editions
* Add icons for game, edition, purchase filters
* Allow filtering by game, edition, purchase from the session list
* Allow editing filtered entities from session list
## 1.0.2 / 2023-02-18 21:48+01:00
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
* Add support for purchase prices
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
## 1.0.1 / 2023-01-30 22:17+01:00
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
* Show markers on smaller graphs to make it clearer which dates the session belong to
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
## 1.0.0 / 2023-01-20 19:54+01:00
* Breaking
* Due to major re-arranging and re-naming of the folder structure, tables also had to be renamed.
* Fixed
* Sort form fields alphabetically (https://git.kucharczyk.xyz/lukas/timetracker/issues/39, https://git.kucharczyk.xyz/lukas/timetracker/issues/40)
* Start session button starts different game than it says (#44)
## 0.2.5 / 2023-01-18 17:01+01:00 ## 0.2.5 / 2023-01-18 17:01+01:00
* New * New

View File

@ -5,10 +5,10 @@
:8000 { :8000 {
handle_path /static/* { handle_path /static/* {
root * /usr/share/caddy root * src/timetracker/static/
file_server file_server
} }
handle { handle {
reverse_proxy backend:8001 reverse_proxy :8001
} }
} }

View File

@ -2,11 +2,11 @@ FROM node as css
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app
RUN npm install && \ RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify npx tailwindcss -i ./src/input.css -o ./src/timetracker/games/static/base.css --minify
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.0.3 ENV VERSION_NUMBER 0.2.5
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
@ -15,13 +15,18 @@ RUN apt update && \
bash \ bash \
vim \ vim \
curl && \ 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/* rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css COPY --from=css /app/src/timetracker/games/static/base.css /home/timetracker/app/src/timetracker/games/static/base.css
COPY entrypoint.sh / COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
@ -31,4 +36,4 @@ RUN pip install --no-cache-dir poetry
RUN poetry install --without dev RUN poetry install --without dev
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] ENTRYPOINT [ "/entrypoint.sh" ]

View File

@ -2,52 +2,49 @@ all: css migrate
initialize: npm css migrate sethookdir loadplatforms initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find src/timetracker/games/templates -type f)
npm: npm:
npm install npm install
css: common/input.css css: src/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css npx tailwindcss -i ./src/input.css -o ./src/timetracker/games/static/base.css
css-dev: css css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch npx tailwindcss -i ./src/input.css -o ./src/timetracker/games/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations poetry run python src/timetracker/manage.py makemigrations
migrate: makemigrations migrate: makemigrations
poetry run python manage.py migrate poetry run python src/timetracker/manage.py migrate
dev: migrate dev: migrate
poetry run python manage.py runserver poetry run python src/timetracker/manage.py runserver
caddy: caddy:
caddy run --watch caddy run --watch
dev-prod: migrate collectstatic dev-prod: migrate collectstatic
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker cd src/timetracker/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
dumpgames: dumpgames:
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml poetry run python src/timetracker/manage.py dumpdata --format yaml games --output tracker_fixture.yaml
loadplatforms: loadplatforms:
poetry run python manage.py loaddata platforms.yaml poetry run python src/timetracker/manage.py loaddata platforms.yaml
loadall:
poetry run python manage.py loaddata data.yaml
loadsample: loadsample:
poetry run python manage.py loaddata sample.yaml poetry run python src/timetracker/manage.py loaddata sample.yaml
createsuperuser: createsuperuser:
poetry run python manage.py createsuperuser poetry run python src/timetracker/manage.py createsuperuser
shell: shell:
poetry run python manage.py shell poetry run python src/timetracker/manage.py shell
collectstatic: collectstatic:
poetry run python manage.py collectstatic --clear --no-input poetry run python src/timetracker/manage.py collectstatic --clear --no-input
poetry.lock: pyproject.toml poetry.lock: pyproject.toml
poetry install poetry install
@ -59,6 +56,6 @@ date:
poetry run python -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 static/* rm -r src/timetracker/static/*
clean: cleanstatic clean: cleanstatic

View File

@ -1,46 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
form label {
@apply dark:text-slate-400;
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
#session-table {
display: grid;
grid-template-columns: 3fr 2fr repeat(2, 1fr) 0.5fr 1fr;
}
.purchase-name > span:nth-child(2) {
@apply ml-4
}
.purchase-name > span:nth-child(2) > a > img {
@apply opacity-0 transition-opacity duration-500
}
.purchase-name:hover > span:nth-child(2) > a > img {
@apply opacity-50
}
.purchase-name > span:nth-child(2) > a > img:hover {
@apply opacity-100
}
#button-container button {
@apply mx-1;
}
th {
@apply text-left;
}
th label {
@apply mr-4;
}

View File

@ -1,17 +0,0 @@
---
services:
timetracker:
image: registry.kucharczyk.xyz/timetracker
build:
context: .
dockerfile: Dockerfile
container_name: timetracker
environment:
- TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
# volumes:
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
ports:
- "8000:8000"
restart: unless-stopped

View File

@ -1,28 +1,17 @@
--- ---
services: services:
backend: timetracker:
image: registry.kucharczyk.xyz/timetracker image: registry.kucharczyk.xyz/timetracker
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: timetracker
environment: environment:
- TZ=Europe/Prague - TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000" user: "1000"
volumes: # volumes:
- "static-files:/home/timetracker/app/static" # - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: restart: unless-stopped
- backend
volumes:
static-files:

View File

@ -2,10 +2,12 @@
# Apply database migrations # Apply database migrations
set -euo pipefail set -euo pipefail
echo "Apply database migrations" echo "Apply database migrations"
poetry run python manage.py migrate poetry run python src/timetracker/manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input poetry run python src/timetracker/manage.py collectstatic --clear --no-input
echo "Starting app" echo "Starting server"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - caddy start
cd src/timetracker || exit
poetry run python -m gunicorn --bind 0.0.0.0:8001 root.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -

View File

@ -1,68 +0,0 @@
from django import forms
from games.models import Game, Platform, Purchase, Session, Edition, Device
class SessionForm(forms.ModelForm):
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name")
)
class Meta:
model = Session
fields = [
"purchase",
"timestamp_start",
"timestamp_end",
"duration_manual",
"device",
"note",
]
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.name} ({obj.platform}, {obj.year_released})"
class PurchaseForm(forms.ModelForm):
edition = EditionChoiceField(queryset=Edition.objects.order_by("name"))
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta:
model = Purchase
fields = [
"edition",
"platform",
"date_purchased",
"date_refunded",
"price",
"price_currency",
"ownership_type",
]
class EditionForm(forms.ModelForm):
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta:
model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "wikidata"]
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = ["name", "group"]
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]

View File

@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]

View File

@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]

View File

@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,16 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% endblock content %}

View File

@ -1,252 +0,0 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from common.plots import playtime_over_time_chart
from common.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,
EditionForm,
DeviceForm,
)
from .models import Game, Platform, Purchase, Session, Edition
def model_counts(request):
return {
"game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(),
}
def add_session(request):
context = {}
initial = {}
now = now_with_tz()
initial["timestamp_start"] = now
last = Session.objects.all().last()
if last != None:
initial["purchase"] = last.purchase
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Add New Session"
context["form"] = form
return render(request, "add.html", context)
def update_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.finish_now()
session.save()
return redirect("list_sessions")
def edit_session(request, session_id=None):
context = {}
session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add.html", context)
def edit_purchase(request, purchase_id=None):
context = {}
purchase = Purchase.objects.get(id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
return render(request, "add.html", context)
def edit_game(request, game_id=None):
context = {}
purchase = Game.objects.get(id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
def edit_platform(request, platform_id=None):
context = {}
purchase = Platform.objects.get(id=platform_id)
form = PlatformForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
def edit_edition(request, edition_id=None):
context = {}
edition = Edition.objects.get(id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Edition"
context["form"] = form
return render(request, "add.html", context)
def start_session(request, last_session_id: int):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions")
def delete_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.delete()
return redirect("list_sessions")
def list_sessions(
request,
filter="",
purchase_id="",
platform_id="",
game_id="",
edition_id="",
ownership_type: str = "",
):
context = {}
context["title"] = "Sessions"
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 == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game":
dataset = Session.objects.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type":
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
dataset = Session.objects.filter(
timestamp_start__gte=datetime.now() - timedelta(days=30)
).order_by("-timestamp_start")
context["title"] = "Last 30 days"
else:
# by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset:
if session.timestamp_end == None and session.duration_manual.seconds == 0:
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True
context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last()
# charts are always oldest->newest
if dataset.count() >= 2:
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context)
def add_purchase(request):
context = {}
now = datetime.now()
initial = {"date_purchased": now}
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Purchase"
return render(request, "add.html", context)
def add_game(request):
context = {}
form = GameForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Game"
return render(request, "add.html", context)
def add_edition(request):
context = {}
form = EditionForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Edition"
return render(request, "add.html", context)
def add_platform(request):
context = {}
form = PlatformForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Platform"
return render(request, "add.html", context)
def add_device(request):
context = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
def index(request):
return redirect("list_sessions_recent")

View File

@ -1,11 +1,11 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.0.3" version = "0.2.5"
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"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker", from = "src"}]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
@ -30,5 +30,10 @@ isort = "^5.11.4"
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
pythonpath = [
"src"
]
[tool.poetry.scripts] [tool.poetry.scripts]
timetracker-import = "common.import_data:import_from_file" timetracker-import = "timetracker.common.util.data_import:import_from_file"

22
src/input.css Normal file
View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
form label {
@apply dark:text-slate-400;
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
#session-table {
display: grid;
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {
@apply mx-1;
}

View File

@ -1,8 +1,7 @@
from timetracker.games.models import Session, Game, Purchase, Platform
import csv import csv
from typing import TypeAlias from typing import TypeAlias
from games.models import Game
DataList: TypeAlias = list[dict[str, str]] | None DataList: TypeAlias = list[dict[str, str]] | None

View File

@ -64,7 +64,7 @@ def get_chart(data, title="", xlabel="", ylabel=""):
plt.switch_backend("SVG") plt.switch_backend("SVG")
fig, ax = plt.subplots() fig, ax = plt.subplots()
fig.set_size_inches(10, 4) fig.set_size_inches(10, 4)
lines = ax.plot(x, y, "-o") ax.plot(x, y)
first = x[0] first = x[0]
last = x[-1] last = x[-1]
difference = last - first difference = last - first
@ -76,11 +76,7 @@ def get_chart(data, title="", xlabel="", ylabel=""):
elif difference.days < 720: elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator()) ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
for line in lines:
line.set_marker("")
else: else:
for line in lines:
line.set_marker("")
ax.xaxis.set_major_locator(mdates.YearLocator()) ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator()) ax.xaxis.set_minor_locator(mdates.MonthLocator())

View File

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

View File

@ -0,0 +1,33 @@
from django import forms
from .models import Game, Platform, Purchase, Session
class SessionForm(forms.ModelForm):
class Meta:
model = Session
fields = [
"purchase",
"timestamp_start",
"timestamp_end",
"duration_manual",
"note",
]
class PurchaseForm(forms.ModelForm):
class Meta:
model = Purchase
fields = ["game", "platform", "date_purchased", "date_refunded"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "wikidata"]
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = ["name", "group"]

View File

@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from common.time import format_duration from timetracker.common.util.time import format_duration
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import F, Manager, Sum from django.db.models import F, Manager, Sum
@ -16,52 +16,14 @@ class Game(models.Model):
return self.name return self.name
class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
year_released = models.IntegerField(default=datetime.today().year)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self):
return self.name
class Purchase(models.Model): class Purchase(models.Model):
PHYSICAL = "ph" game = models.ForeignKey("Game", on_delete=models.CASCADE)
DIGITAL = "di"
DIGITALUPGRADE = "du"
RENTED = "re"
BORROWED = "bo"
TRIAL = "tr"
DEMO = "de"
PIRATED = "pi"
OWNERSHIP_TYPES = [
(PHYSICAL, "Physical"),
(DIGITAL, "Digital"),
(DIGITALUPGRADE, "Digital Upgrade"),
(RENTED, "Rented"),
(BORROWED, "Borrowed"),
(TRIAL, "Trial"),
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE) platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
def __str__(self): def __str__(self):
platform_info = self.platform return f"{self.game} ({self.platform})"
if self.platform != self.edition.platform:
platform_info = f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
class Platform(models.Model): class Platform(models.Model):
@ -86,7 +48,6 @@ class Session(models.Model):
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, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
@ -124,23 +85,3 @@ class Session(models.Model):
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
super(Session, self).save(*args, **kwargs) super(Session, self).save(*args, **kwargs)
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data" class="mx-auto">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit"/>
</form>
{% endblock content %}

View File

@ -17,8 +17,8 @@
<div class="dark:bg-gray-800 min-h-screen"> <div class="dark:bg-gray-800 min-h-screen">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'index' %}" class="flex items-center">
<span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span> <span class="text-4xl"></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a> </a>
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
@ -27,14 +27,10 @@
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
{% if game_available and platform_available %} {% if game_available and platform_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
{% endif %}
{% if edition_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{% endif %} {% endif %}
{% if purchase_available %} {% if purchase_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
{% endif %} {% endif %}
{% if session_count > 0 %} {% if session_count > 0 %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>

View File

@ -1,8 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% block title %}Sessions{% endblock title %}
{% block title %}{{ title }}{% endblock title %}
{% block content %} {% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400"> <div class="text-center text-xl mb-4 dark:text-slate-400">
@ -12,33 +10,15 @@
{% if dataset.count >= 1 %} {% if dataset.count >= 1 %}
<div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div> <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
{% endif %} {% endif %}
{% if purchase or platform or edition or game or ownership_type %} {% if purchase or platform or game %}
<span class="block"> <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">
<a class="text-red-400 inline" href="{% url 'list_sessions' %}"> <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 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"> </svg>
<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" /> </a><span>Filtering by "{% firstof purchase platform game %}"</span>
</svg> {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %}
</a>
{% if purchase %}
Filtering by purchase "{{ purchase }}"
(<a href="{% url 'edit_purchase' purchase.id %}" class="hover:underline dark:text-white">Edit</a>)
{% elif platform %}
Filtering by purchase "{{ platform }}"
(<a href="{% url 'edit_platform' platform.id %}" class="hover:underline dark:text-white">Edit</a>)
{% elif game %}
Filtering by purchase "{{ game }}"
(<a href="{% url 'edit_game' game.id %}" class="hover:underline dark:text-white">Edit</a>)
{% elif edition %}
Filtering by purchase "{{ edition }}"
(<a href="{% url 'edit_edition' edition.id %}" class="hover:underline dark:text-white">Edit</a>)
{% elif ownership_type %}
Filtering by ownership type "{{ ownership_type }}"
{% endif%}
</span>
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_edition' purchase.edition.id %}">See all platforms</a>{% endif %}
{% endif %} {% endif %}
{% if dataset.count >= 1 %} {% if dataset.count >= 1 %}
<a href="{% url 'start_session' last.id %}"> <a class="clear-both" 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 "> <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"> <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" /> <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" />
@ -48,38 +28,16 @@
</a> </a>
{% endif %} {% endif %}
</div> </div>
<div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-2xl 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">Purchase</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">Platform</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">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 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">Duration</div>
<div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div> <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
{% for data in dataset %} {% for data in dataset %}
<div class="purchase-name"> <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>
<span class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">{{ data.purchase.edition }} <span class="dark:text-slate-400">(<a class="hover:underline" href="{% url 'list_sessions_by_ownership_type' data.purchase.ownership_type %}">{{ data.purchase.get_ownership_type_display }}</a>)</span></span> <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>
<span>
<a href="{% url 'list_sessions_by_game' data.purchase.edition.game.id %}">
<img src="{% static 'icons/game_white.png' %}" width="32" class="inline" alt="Filter by this game" title="Filter by this game" />
</a>
<a href="{% url 'list_sessions_by_edition' data.purchase.edition.id %}">
<img src="{% static 'icons/edition_white.png' %}" width="32" class="inline" alt="Filter by this edition" title="Filter by this edition" />
</a>
<a href="{% url 'list_sessions_by_purchase' data.purchase.id %}">
<img src="{% static 'icons/purchase_white.png' %}" width="32" class="inline" alt="Filter by this purchase" title="Filter by this purchase" />
</a>
</span>
</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 %}">
{% if data.purchase.platform != data.purchase.edition.platform %}
{{data.purchase.edition.platform}} on {{ data.purchase.platform }}
{% else %}
{{ data.purchase.platform }}
{% endif %}
</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">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
<div class="dark:text-slate-400 text-center"> <div class="dark:text-slate-400 text-center">
{% if data.unfinished %} {% if data.unfinished %}
@ -104,14 +62,12 @@
</button> </button>
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'edit_session' data.id %}"> <button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-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-7 h-4 rounded-lg ">
<button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-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-7 h-4 rounded-lg "> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" /> <path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" /> </svg>
</svg> </button>
</button>
</a>
<a href="{% url 'delete_session' data.id %}"> <a href="{% url 'delete_session' data.id %}">
<button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center bg-red-600 hover:bg-red-700 focus:ring-red-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-7 h-4 rounded-lg "> <button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center bg-red-600 hover:bg-red-700 focus:ring-red-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-7 h-4 rounded-lg ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">

View File

@ -13,7 +13,9 @@ def version_date():
"%d-%b-%Y %H:%m", "%d-%b-%Y %H:%m",
time.gmtime( time.gmtime(
os.path.getmtime( os.path.getmtime(
os.path.abspath(os.path.join(settings.BASE_DIR, "pyproject.toml")) os.path.abspath(
os.path.join(settings.BASE_DIR, "..", "..", "pyproject.toml")
)
) )
), ),
) )

View File

@ -1,15 +1,9 @@
from django.urls import path from django.urls import path
from games import views from . import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path(
"list-sessions/recent",
views.list_sessions,
{"filter": "recent"},
name="list_sessions_recent",
),
path("add-game/", views.add_game, name="add_game"), path("add-game/", views.add_game, name="add_game"),
path("add-platform/", views.add_platform, name="add_platform"), path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"), path("add-session/", views.add_session, name="add_session"),
@ -19,7 +13,7 @@ urlpatterns = [
name="update_session", name="update_session",
), ),
path( path(
"start-session/<int:last_session_id>", "start-session/<int:purchase_id>",
views.start_session, views.start_session,
name="start_session", name="start_session",
), ),
@ -29,13 +23,6 @@ urlpatterns = [
name="delete_session", name="delete_session",
), ),
path("add-purchase/", views.add_purchase, name="add_purchase"), path("add-purchase/", views.add_purchase, name="add_purchase"),
path("add-edition/", views.add_edition, name="add_edition"),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
path("edit-game/<int:game_id>", views.edit_game, name="edit_game"),
path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
path("add-device/", views.add_device, name="add_device"),
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
path("list-sessions/", views.list_sessions, name="list_sessions"), path("list-sessions/", views.list_sessions, name="list_sessions"),
path( path(
"list-sessions/by-purchase/<int:purchase_id>", "list-sessions/by-purchase/<int:purchase_id>",
@ -55,16 +42,4 @@ urlpatterns = [
{"filter": "game"}, {"filter": "game"},
name="list_sessions_by_game", name="list_sessions_by_game",
), ),
path(
"list-sessions/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "edition"},
name="list_sessions_by_edition",
),
path(
"list-sessions/by-ownership/<str:ownership_type>",
views.list_sessions,
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
] ]

View File

@ -0,0 +1,135 @@
from datetime import datetime
from zoneinfo import ZoneInfo
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):
return {
"game_available": Game.objects.count() != 0,
"platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(),
}
def add_session(request):
context = {}
initial = {}
now = now_with_tz()
initial["timestamp_start"] = now
last = Session.objects.all().last()
if last != None:
initial["purchase"] = last.purchase
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Add New Session"
context["form"] = form
return render(request, "add.html", context)
def update_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.finish_now()
session.save()
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):
session = Session.objects.get(id=session_id)
session.delete()
return redirect("list_sessions")
def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""):
context = {}
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:
# by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset:
if session.timestamp_end == None and session.duration_manual.seconds == 0:
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True
context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last()
# charts are always oldest->newest
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context)
def add_purchase(request):
context = {}
now = datetime.now()
initial = {"date_purchased": now}
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Purchase"
return render(request, "add.html", context)
def add_game(request):
context = {}
form = GameForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Game"
return render(request, "add.html", context)
def add_platform(request):
context = {}
form = PlatformForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Platform"
return render(request, "add.html", context)
def index(request):
context = {}
context["total_duration"] = Session().duration_sum
context["title"] = "Index"
return render(request, "index.html", context)

2
manage.py → src/timetracker/manage.py Executable file → Normal file
View File

@ -6,7 +6,7 @@ import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "root.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:

View File

@ -1,5 +1,5 @@
""" """
ASGI config for timetracker project. ASGI config for root project.
It exposes the ASGI callable as a module-level variable named ``application``. It exposes the ASGI callable as a module-level variable named ``application``.
@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "root.settings")
application = get_asgi_application() application = get_asgi_application()

View File

@ -54,7 +54,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = "timetracker.urls" ROOT_URLCONF = "root.urls"
TEMPLATES = [ TEMPLATES = [
{ {
@ -73,7 +73,7 @@ TEMPLATES = [
}, },
] ]
WSGI_APPLICATION = "timetracker.wsgi.application" WSGI_APPLICATION = "root.wsgi.application"
# Database # Database

View File

@ -19,7 +19,7 @@ from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker")), path("", RedirectView.as_view(url="/tracker/list-sessions")),
path("tracker/", include("games.urls")), path("tracker/", include("games.urls")),
] ]

View File

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "root.settings")
application = get_wsgi_application() application = get_wsgi_application()

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
darkMode: 'class', darkMode: 'class',
content: ["./**/*.{html,js}"], content: ["./src/**/*.{html,js}"],
theme: { theme: {
fontFamily: { fontFamily: {
sans: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-serif'],

View File

@ -1,7 +1,7 @@
import unittest import unittest
from datetime import timedelta from datetime import timedelta
from common.time import format_duration from timetracker.common.util.time import format_duration
class FormatDurationTest(unittest.TestCase): class FormatDurationTest(unittest.TestCase):