41 Commits

Author SHA1 Message Date
2640a49734 Version 1.0.3
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-20 17:18:26 +01:00
65c175afb2 Allow editing filtered entities from session list 2023-02-20 17:16:19 +01:00
0814071a26 Improve display of editions on purchase form 2023-02-20 17:15:21 +01:00
5f845f866e Sort platforms alphabetically on edition form 2023-02-20 17:14:43 +01:00
c3d4697470 Add wikidata ID and year for editions 2023-02-20 17:13:35 +01:00
77293f03e9 Add icons
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-19 17:20:57 +01:00
1fa364e2ec Change timetracker icon 2023-02-19 16:35:04 +01:00
4a6f4a2f9a Add icons for session filters
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-19 16:18:14 +01:00
9590988b6a Fix error when generating charts with less than 2 entries
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-19 14:55:06 +01:00
938c82a395 Allow filtering by game, edition, purchase from the session list 2023-02-19 14:36:12 +01:00
33939f631c Fix form styling
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 22:36:26 +01:00
ac8cd6534a Version 1.0.2
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 21:48:55 +01:00
51d8e953c0 Allow editing editions 2023-02-18 21:47:25 +01:00
2eec677f41 Allow editing purchases 2023-02-18 21:44:19 +01:00
f2eb14d3ef Fix session starting 2023-02-18 21:43:51 +01:00
c337d2200f Fix ownership display
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 21:12:44 +01:00
8a8b05b0bd Add support for device info
Closes #49
2023-02-18 21:12:18 +01:00
9446065271 Add support for purchase ownership information
Closes #48
2023-02-18 20:57:03 +01:00
755093845d Add support for prices on purchases 2023-02-18 20:56:23 +01:00
d4ab0596da Re-introduce index view
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 20:50:36 +01:00
8dcbe2f0ad Fix displaying of "filterying by..." text 2023-02-18 20:50:19 +01:00
25bc74eff1 Add support for game editions (#28) 2023-02-18 20:49:46 +01:00
8a7d083fb2 Fix make css 2023-02-18 20:35:59 +01:00
8296ebcf31 Fix redirect to index
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-06 17:23:44 +01:00
04a4f2e0be Order homepage sessions from newest
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-31 16:37:44 +01:00
4070b4e46e Version 1.0.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-30 22:17:47 +01:00
4892218c83 Show only last 30 days on homepage
Fixes #47
2023-01-30 22:16:28 +01:00
6b00a950ce Show markers on smaller graphs 2023-01-30 22:01:27 +01:00
feee9d6dac Make it possible to edit sessions
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #46
2023-01-30 17:38:44 +01:00
9654fb017d Separate Docker container by branch
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 11:36:34 +01:00
1741397ee7 Prepare changelog
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 00:50:44 +01:00
da0c8d710b Ignore dist directory
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-22 11:02:26 +01:00
215374167b Version 1.0.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-20 19:58:40 +01:00
77268ae92f Add make loadall 2023-01-20 19:58:31 +01:00
c42687a072 Change ENTRYPOINT to CMD 2023-01-20 19:58:09 +01:00
ca16345374 Fix start session button starting different game than it says
Fixes #44
2023-01-20 19:57:45 +01:00
3a3045be91 Sort form fields alphabetically
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #39
Fixes #40
2023-01-20 18:27:30 +01:00
d40612af72 Remove Caddy
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-20 17:15:53 +01:00
18e8f93261 Additional fixes
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-20 15:06:42 +01:00
56e5dfaa03 Rename project, part 2 (#42)
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #42
2023-01-20 13:37:46 +00:00
2f00be455d Rename project (#41)
All checks were successful
continuous-integration/drone/push Build is passing
The old naming scheme was causing confusion and probably errors.

Reviewed-on: #41
2023-01-19 19:35:25 +00:00
69 changed files with 6351 additions and 1431 deletions

View File

@ -11,15 +11,30 @@ steps:
- poetry install
- poetry env info
- poetry run pytest
- name: build container
- name: build container (prod)
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- 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:
event:
- push
- cron
exclude:
- pull_request
- cron

4
.gitignore vendored
View File

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

View File

@ -1,3 +1,31 @@
## 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
* New

View File

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

View File

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

View File

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

View File

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

46
common/input.css Normal file
View File

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

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

View File

@ -0,0 +1,17 @@
---
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,17 +1,28 @@
---
services:
timetracker:
backend:
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"
volumes:
- "static-files:/home/timetracker/app/static"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports:
- "8000:8000"
restart: unless-stopped
depends_on:
- backend
volumes:
static-files:

View File

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

View File

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

View File

@ -222,4 +222,4 @@ Remnant: From the Ashes,PS4,2021-03-08 02:41,2021-03-08 05:38
Remnant: From the Ashes,PS4,2021-03-07 03:21,2021-03-07 06:49
13 Sentinels: Aegis Rim,PS4,2021-03-07 03:20,2021-03-07 03:21
DARK SOULS™ II: Scholar of the First Sin,PS4,2020-10-24 23:43,2020-10-25 01:18
Ghost of Tsushima,PS4,2020-10-24 23:14,2020-10-24 23:22
Ghost of Tsushima,PS4,2020-10-24 23:14,2020-10-24 23:22
1 name platform start end
222 Remnant: From the Ashes PS4 2021-03-07 03:21 2021-03-07 06:49
223 13 Sentinels: Aegis Rim PS4 2021-03-07 03:20 2021-03-07 03:21
224 DARK SOULS™ II: Scholar of the First Sin PS4 2020-10-24 23:43 2020-10-25 01:18
225 Ghost of Tsushima PS4 2020-10-24 23:14 2020-10-24 23:22

68
games/forms.py Normal file
View File

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

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

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

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

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

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

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

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

@ -0,0 +1,23 @@
# 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),
),
]

View File

@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from timetracker.common.util.time import format_duration
from common.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
@ -16,14 +16,52 @@ class Game(models.Model):
return self.name
class Purchase(models.Model):
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):
PHYSICAL = "ph"
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)
date_purchased = models.DateField()
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):
return f"{self.game} ({self.platform})"
platform_info = 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):
@ -48,6 +86,7 @@ class Session(models.Model):
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
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)
objects = SessionQuerySet.as_manager()
@ -85,3 +124,23 @@ class Session(models.Model):
else:
self.duration_calculated = timedelta(0)
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()})"

5436
games/static/base.css Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

16
games/templates/add.html Normal file
View File

@ -0,0 +1,16 @@
{% 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

@ -17,8 +17,8 @@
<div class="dark:bg-gray-800 min-h-screen">
<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">
<a href="{% url 'index' %}" class="flex items-center">
<span class="text-4xl"></span>
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
<div class="w-full md:block md:w-auto">
@ -27,10 +27,14 @@
<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>
{% 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>
{% endif %}
{% 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_device' %}">New Device</a></li>
{% endif %}
{% if session_count > 0 %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>

View File

@ -1,6 +1,8 @@
{% extends 'base.html' %}
{% block title %}Sessions{% endblock title %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400">
@ -10,15 +12,33 @@
{% 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 %}
{% if purchase or platform or edition or game or ownership_type %}
<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">
<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>
{% 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 %}
{% if dataset.count >= 1 %}
<a class="clear-both" href="{% url 'start_session' dataset.last.purchase.id %}">
<a href="{% url 'start_session' last.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" />
@ -28,16 +48,38 @@
</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 id="session-table" class="gap-4 shadow rounded-xl max-w-screen-2xl 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">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 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="purchase-name">
<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>
<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">
{% if data.unfinished %}
@ -62,12 +104,14 @@
</button>
</a>
{% endif %}
<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">
<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" />
</svg>
</button>
<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 ">
<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="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>
</button>
</a>
<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 ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">

View File

@ -13,9 +13,7 @@ def version_date():
"%d-%b-%Y %H:%m",
time.gmtime(
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,9 +1,15 @@
from django.urls import path
from . import views
from games import views
urlpatterns = [
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-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"),
@ -13,7 +19,7 @@ urlpatterns = [
name="update_session",
),
path(
"start-session/<int:purchase_id>",
"start-session/<int:last_session_id>",
views.start_session,
name="start_session",
),
@ -23,6 +29,13 @@ urlpatterns = [
name="delete_session",
),
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/by-purchase/<int:purchase_id>",
@ -42,4 +55,16 @@ urlpatterns = [
{"filter": "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",
),
]

252
games/views.py Normal file
View File

@ -0,0 +1,252 @@
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")

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

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

View File

@ -1,11 +1,11 @@
[tool.poetry]
name = "timetracker"
version = "0.2.5"
version = "1.0.3"
description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL"
readme = "README.md"
packages = [{include = "timetracker", from = "src"}]
packages = [{include = "timetracker"}]
[tool.poetry.dependencies]
python = "^3.10"
@ -30,10 +30,5 @@ isort = "^5.11.4"
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
pythonpath = [
"src"
]
[tool.poetry.scripts]
timetracker-import = "timetracker.common.util.data_import:import_from_file"
timetracker-import = "common.import_data:import_from_file"

View File

@ -1,22 +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 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {
@apply mx-1;
}

View File

@ -1,33 +0,0 @@
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"]

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +0,0 @@
{% 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

@ -1,135 +0,0 @@
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)

View File

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

View File

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

View File

@ -1,5 +1,5 @@
"""
ASGI config for root project.
ASGI config for timetracker project.
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
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "root.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
application = get_asgi_application()

View File

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

View File

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

View File

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