2 Commits

Author SHA1 Message Date
394dd4f9f8 Version 1.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:01:55 +01:00
c358b1aaa0 Adding new games is easier 2023-11-09 21:01:01 +01:00
43 changed files with 531 additions and 1332 deletions

View File

@ -5,13 +5,4 @@
.venv .venv
.vscode .vscode
node_modules node_modules
static src/timetracker/static/*
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile

View File

@ -5,12 +5,11 @@ name: default
steps: steps:
- name: test - name: test
image: python:3.12 image: python:3.10
commands: commands:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
- poetry env info - poetry env info
- poetry run python manage.py migrate
- poetry run pytest - poetry run pytest
- name: build-prod - name: build-prod

View File

@ -1,27 +0,0 @@
name: Django CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

View File

@ -1,10 +0,0 @@
repos:
- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)

View File

@ -1,28 +1,3 @@
## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
* add purchase: only allow choosing purchases of selected edition
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00
## New
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00 ## 1.4.0 / 2023-11-09 21:01+01:00
### New ### New

View File

@ -1,45 +1,27 @@
FROM python:3.12.0-slim-bullseye FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
ENV VERSION_NUMBER=1.5.1 \ FROM python:3.10.9-slim-bullseye
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_ROOT_USER_ACTION=ignore \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
RUN apt-get update && apt-get upgrade -y \ ENV VERSION_NUMBER 1.4.0
&& apt-get install --no-install-recommends -y \ ENV PROD 1
bash \ ENV PYTHONUNBUFFERED=1
curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \
&& poetry --version \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker \ RUN useradd -m --uid 1000 timetracker
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
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 entrypoint.sh / COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]

View File

@ -66,16 +66,6 @@ textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} }
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,

View File

@ -1,5 +1,12 @@
import re import re
from datetime import timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):
@ -37,7 +44,7 @@ def format_duration(
# timestamps where end is before start # timestamps where end is before start
if seconds_total < 0: if seconds_total < 0:
seconds_total = 0 seconds_total = 0
days = hours = hours_float = minutes = seconds = 0 days = hours = minutes = seconds = 0
remainder = seconds = seconds_total remainder = seconds = seconds_total
if "%d" in format_string: if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds) days, remainder = divmod(seconds_total, day_seconds)
@ -48,7 +55,7 @@ def format_duration(
minutes, seconds = divmod(remainder, minute_seconds) minutes, seconds = divmod(remainder, minute_seconds)
literals = { literals = {
"d": str(days), "d": str(days),
"H": str(hours) if "m" not in format_string else str(hours_float), "H": str(hours),
"m": str(minutes), "m": str(minutes),
"s": str(seconds), "s": str(seconds),
"r": str(seconds_total), "r": str(seconds_total),

View File

@ -10,14 +10,13 @@ services:
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000" user: "1000"
volumes: volumes:
- "static-files:/var/www/django/static" - "static-files:/home/timetracker/app/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: caddy image: caddy
volumes: volumes:
- "static-files:/usr/share/caddy:ro" - "static-files:/usr/share/caddy"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile" - "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports: ports:
- "8000:8000" - "8000:8000"
@ -27,4 +26,3 @@ services:
volumes: volumes:
static-files: static-files:

View File

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

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.urls import reverse
from games.models import Device, Edition, Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session, Edition, Device
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
@ -51,32 +50,11 @@ class IncludePlatformSelect(forms.Select):
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField( edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False,
)
class Meta: class Meta:
widgets = { widgets = {
@ -94,32 +72,8 @@ class PurchaseForm(forms.ModelForm):
"price", "price",
"price_currency", "price_currency",
"ownership_type", "ownership_type",
"type",
"related_purchase",
"name",
] ]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select): class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10 # Generated by Django 4.1.5 on 2023-11-06 11:10
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14 # Generated by Django 4.1.5 on 2023-11-06 18:14
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
def rename_duplicates(apps, schema_editor): def rename_duplicates(apps, schema_editor):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53 # Generated by Django 4.1.5 on 2023-11-06 16:53
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,27 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]

View File

@ -1,44 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0030_alter_purchase_name"),
]
operations = [
migrations.AddField(
model_name="device",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="edition",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="game",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="platform",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="session",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="session",
options={"get_latest_by": "timestamp_start"},
),
migrations.AddField(
model_name="session",
name="modified_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="device",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="edition",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="platform",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="session",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -1,11 +1,11 @@
from datetime import timedelta from datetime import datetime, timedelta
from typing import Any
from django.core.exceptions import ValidationError from zoneinfo import ZoneInfo
from django.db import models
from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.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
class Game(models.Model): class Game(models.Model):
@ -13,7 +13,6 @@ class Game(models.Model):
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -44,7 +43,6 @@ class Edition(models.Model):
) )
year_released = models.IntegerField(null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.sort_name return self.sort_name
@ -73,9 +71,6 @@ class PurchaseQueryset(models.QuerySet):
def finished(self): def finished(self):
return self.filter(date_finished__isnull=False) return self.filter(date_finished__isnull=False)
def games_only(self):
return self.filter(type=Purchase.GAME)
class Purchase(models.Model): class Purchase(models.Model):
PHYSICAL = "ph" PHYSICAL = "ph"
@ -96,16 +91,6 @@ class Purchase(models.Model):
(DEMO, "Demo"), (DEMO, "Demo"),
(PIRATED, "Pirated"), (PIRATED, "Pirated"),
] ]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
objects = PurchaseQueryset().as_manager() objects = PurchaseQueryset().as_manager()
@ -121,46 +106,17 @@ class Purchase(models.Model):
ownership_type = models.CharField( ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
additional_info = [ platform_info = self.platform
self.get_type_display() if self.type != Purchase.GAME else "", if self.platform != self.edition.platform:
f"{self.edition.platform} version on {self.platform}" platform_info = f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs)
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -178,9 +134,6 @@ class SessionQuerySet(models.QuerySet):
class Session(models.Model): class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
@ -194,25 +147,23 @@ class Session(models.Model):
default=None, default=None,
) )
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.is_manual() else "" mark = ", manual" if self.duration_manual != None else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = timezone.now() self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def start_now(): def start_now():
self.timestamp_start = timezone.now() self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
calculated = timedelta(0) calculated = timedelta(0)
if self.is_manual(): if not self.duration_manual in (None, 0, timedelta(0)):
manual = self.duration_manual manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None: if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start calculated = self.timestamp_end - self.timestamp_start
@ -222,9 +173,6 @@ class Session(models.Model):
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
return result return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property @property
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted() return Session.objects.all().total_duration_formatted()
@ -260,7 +208,6 @@ class Device(models.Model):
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN) type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_type_display()})" return f"{self.name} ({self.get_type_display()})"

View File

@ -1222,28 +1222,6 @@ textarea) {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
:is(.dark form input:disabled),:is(.dark
select:disabled),:is(.dark
textarea:disabled) {
--tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -1442,10 +1420,6 @@ th label {
padding-right: 1rem; padding-right: 1rem;
} }
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 { .sm\:pl-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
} }
@ -1454,10 +1428,6 @@ th label {
padding-left: 1rem; padding-left: 1rem;
} }
.sm\:pl-6 {
padding-left: 1.5rem;
}
.sm\:decoration-2 { .sm\:decoration-2 {
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }

View File

@ -1,43 +1,12 @@
import { import { syncSelectInputUntilChanged } from './utils.js'
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenFalse,
} from "./utils.js";
let syncData = [ let syncData = [
{ {
source: "#id_edition", "source": "#id_edition",
source_value: "dataset.platform", "source_value": "dataset.platform",
target: "#id_platform", "target": "#id_platform",
target_value: "value", "target_value": "value"
},
];
syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => {
setupElementHandlers();
};
document.body.addEventListener('htmx:beforeRequest', function(event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === 'id_edition') {
var idEditionValue = document.getElementById('id_edition').value;
// Condition to check - replace this with your actual logic
if (idEditionValue != '') {
event.preventDefault(); // This cancels the HTMX request
}
} }
}); ]
syncSelectInputUntilChanged(syncData, "form")

View File

@ -87,84 +87,4 @@ function getValueFromProperty(sourceElement, property) {
} }
} }
/** export { toISOUTCString, syncSelectInputUntilChanged };
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1))
}
else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector)
}
else {
return document.getElementsByTagName(selector)
}
}
/**
* @description Applies different behaviors to elements based on multiple conditional configurations.
* Each configuration is an array containing a condition function, an array of target element selectors,
* and two callback functions for handling matched and unmatched conditions.
* @param {...Array} configs Each configuration is an array of the form:
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/
function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) {
targetElements.forEach(elementName => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach(elementName => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn2(el);
}
});
}
});
}
function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value != targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };

View File

@ -13,7 +13,7 @@
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark" hx-indicator="#indicator" hx-boost="true"> <body class="dark">
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" /> <img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
<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">

View File

@ -12,6 +12,7 @@
id="last-session-start" id="last-session-start"
href="{% url 'start_session_same_as_last' last.id %}" href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session_same_as_last' last.id %}" hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-indicator="#indicator"
hx-swap="afterbegin" hx-swap="afterbegin"
hx-target=".responsive-table tbody" hx-target=".responsive-table tbody"
hx-select=".responsive-table tbody tr:first-child" hx-select=".responsive-table tbody tr:first-child"
@ -23,7 +24,6 @@
</div> </div>
{% endif %} {% endif %}
{% if dataset.count != 0 %}
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
<tr> <tr>
@ -74,7 +74,4 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
{% endif %}
{% endblock content %} {% endblock content %}

View File

@ -1 +0,0 @@
{{ form.related_purchase }}

View File

@ -45,10 +45,6 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
</tr> </tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -181,14 +177,7 @@
<tbody> <tbody>
{% for purchase in all_purchased_this_year %} {% for purchase in all_purchased_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type != "game" %}
({{ purchase.name }}, {{ purchase.get_type_display }})
{% endif %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr> </tr>

View File

@ -13,47 +13,38 @@
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</h1> </h1>
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
{{ hours_sum }} <span class="dark:text-slate-500">total</span> {{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span> {{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) </h2> ({{ playrange }}) </h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span></h1> <h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
<ul> <ul>
{% for edition in editions %} {% for edition in editions %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }}) {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %} {% if edition.wikidata %}
<span class="hidden sm:inline"> <span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}"> <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/> <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
</a> </a>
</span> </span>
{% endif %} {% endif %}
{% url 'edit_edition' edition.id as edit_url %} {% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul>
{% for purchase in purchases %}
<li class="sm:pl-2 flex items-center">
{{ purchase.platform }}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<ul>
{% for purchase in edition.game_purchases %}
<li class="sm:pl-6 flex items-center">
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
<ul>
{% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-12 flex items-center">
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
{% endfor %}
</ul>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions Sessions
<span class="dark:text-slate-500"> <span class="dark:text-slate-500">

View File

@ -11,6 +11,7 @@ urlpatterns = [
name="list_sessions_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-game-unified/", views.add_game_unified, name="add_game_unified"),
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"),
path( path(
@ -44,11 +45,6 @@ urlpatterns = [
views.add_purchase, views.add_purchase,
name="add_purchase_for_edition", name="add_purchase_for_edition",
), ),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"), path("add-edition/", views.add_edition, name="add_edition"),
path( path(
"add-edition-for-game/<int:game_id>", "add-edition-for-game/<int:game_id>",

View File

@ -1,31 +1,24 @@
from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Callable from django.conf import settings
from django.db.models import Sum, F, Count
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, Prefetch, Sum
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate
from django.http import ( from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from typing import Callable, Any
from zoneinfo import ZoneInfo
from common.time import format_duration
from common.utils import safe_division
from .forms import ( from .forms import (
DeviceForm,
EditionForm,
GameForm, GameForm,
PlatformForm, PlatformForm,
PurchaseForm, PurchaseForm,
SessionForm, SessionForm,
EditionForm,
DeviceForm,
) )
from .models import Edition, Game, Platform, Purchase, Session from .models import Game, Platform, Purchase, Session, Edition
def model_counts(request): def model_counts(request):
@ -39,15 +32,14 @@ def model_counts(request):
def stats_dropdown_year_range(request): def stats_dropdown_year_range(request):
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)} return {"stats_dropdown_year_range": range(2018, 2024)}
return result
def add_session(request, purchase_id=None): def add_session(request, purchase_id=None):
context = {} context = {}
initial = {"timestamp_start": timezone.now()} initial = {"timestamp_start": now_with_tz()}
last = Session.objects.last() last = Session.objects.all().last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["purchase"] = last.purchase
@ -121,8 +113,7 @@ def edit_purchase(request, purchase_id=None):
return redirect("list_sessions") return redirect("list_sessions")
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
context["script_name"] = "add_purchase.js" return render(request, "add.html", context)
return render(request, "add_purchase.html", context)
@use_custom_redirect @use_custom_redirect
@ -139,52 +130,34 @@ def edit_game(request, game_id=None):
def view_game(request, game_id=None): def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch = Prefetch( context["title"] = "View Game"
"related_purchases", context["game"] = game
queryset=Purchase.objects.exclude(type=Purchase.GAME), context["editions"] = Edition.objects.filter(game_id=game_id)
to_attr="nongame_related_purchases", context["purchases"] = Purchase.objects.filter(edition__game_id=game_id)
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
) )
game_purchases_prefetch = Prefetch( context["session_average"] = round(
"purchase_set", (context["total_hours"]) / int(context["sessions"].count()), 1
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
) )
# here first and last is flipped
# because sessions are ordered from newest to oldest
# so the most recent are on top
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y")
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y")
sessions = Session.objects.filter(purchase__edition__game=game) context["playrange"] = (
session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
playrange = (
playrange_start playrange_start
if playrange_start == playrange_end if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}" else f"{playrange_start}{playrange_end}"
) )
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes": sessions.exclude(note=""),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
}
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@ -215,24 +188,17 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
@use_custom_redirect @use_custom_redirect
def start_game_session(request, game_id: int): def start_game_session(request, game_id: int):
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest() last_session = (
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": timezone.now(), "timestamp_start": now_with_tz(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -245,7 +211,7 @@ def start_session_same_as_last(request, last_session_id: int):
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": timezone.now(), "timestamp_start": now_with_tz(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -287,29 +253,27 @@ def list_sessions(
dataset = Session.objects.filter(purchase__ownership_type=ownership_type) dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent": elif filter == "recent":
current_year = timezone.now().year current_year = datetime.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1)) first_day_of_year = datetime(current_year, 1, 1)
dataset = Session.objects.filter( dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start") ).order_by("-timestamp_start")
context["title"] = "This year" context["title"] = "This year"
else: else:
# by default, sort from newest to oldest # by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start") dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta( if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0 seconds=0
): ):
session.timestamp_end = timezone.now() session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted() context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset context["dataset"] = dataset
try: # cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.latest() context["last"] = Session.objects.all().order_by("timestamp_start").last()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@ -319,7 +283,7 @@ def stats(request, year: int = 0):
if selected_year: if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
year = timezone.now().year year = now_with_tz().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year) this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK" selected_currency = "CZK"
unique_days = ( unique_days = (
@ -343,9 +307,7 @@ def stats(request, year: int = 0):
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter( this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
date_finished__isnull=True date_finished__isnull=True
).filter( )
type=Purchase.GAME
) # do not count DLC etc.
this_year_purchases_unfinished_percent = int( this_year_purchases_unfinished_percent = int(
safe_division( safe_division(
@ -369,7 +331,7 @@ def stats(request, year: int = 0):
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price")) total_spent=Sum(F("price"))
) )
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"]
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions) Game.objects.filter(edition__purchase__session__in=this_year_sessions)
@ -418,15 +380,9 @@ def stats(request, year: int = 0):
"spent_per_game": int( "spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded.count()) safe_division(total_spent, this_year_purchases_without_refunded.count())
), ),
"all_finished_this_year": purchases_finished_this_year.order_by( "all_finished_this_year": purchases_finished_this_year,
"date_finished" "this_year_finished_this_year": purchases_finished_this_year_released_this_year,
), "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year,
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"date_finished"
),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(), "total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"], "unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100), "unique_days_percent": int(unique_days["dates"] / 365 * 100),
@ -452,7 +408,7 @@ def stats(request, year: int = 0):
def add_purchase(request, edition_id=None): def add_purchase(request, edition_id=None):
context = {} context = {}
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": now_with_tz()}
if request.method == "POST": if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial) form = PurchaseForm(request.POST or None, initial=initial)
@ -521,12 +477,7 @@ def add_edition(request, game_id=None):
if game_id: if game_id:
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
form = EditionForm( form = EditionForm(
initial={ initial={"game": game, "name": game.name, "sort_name": game.sort_name}
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
) )
else: else:
form = EditionForm() form = EditionForm()

792
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,15 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.1" version = "1.4.0"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.10"
django = "^4.2.0" django = "^4.1.4"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
@ -23,10 +23,6 @@ werkzeug = "^2.2.2"
djhtml = "^1.5.2" djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4" isort = "^5.11.4"
pre-commit = "^3.5.0"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View File

@ -1,90 +0,0 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.test import TestCase
from django.urls import reverse
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class PathWorksTest(TestCase):
def setUp(self) -> None:
pl = Platform(name="Test Platform")
pl.save()
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition", platform=pl)
e.save()
p = Purchase(
edition=e,
platform=pl,
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.testSession = s
return super().setUp()
def test_add_device_returns_200(self):
url = reverse("add_device")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_platform_returns_200(self):
url = reverse("add_platform")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_game_returns_200(self):
url = reverse("add_game")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_edition_returns_200(self):
url = reverse("add_edition")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_purchase_returns_200(self):
url = reverse("add_purchase")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_session_returns_200(self):
url = reverse("add_session")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_session_returns_200(self):
id = self.testSession.id
url = reverse("edit_session", args=[id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_view_game_returns_200(self):
url = reverse("view_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_game_returns_200(self):
url = reverse("edit_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_list_sessions_returns_200(self):
url = reverse("list_sessions")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View File

@ -1,40 +0,0 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class FormatDurationTest(TestCase):
def setUp(self) -> None:
return super().setUp()
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition")
e.save()
p = Purchase(
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.assertEqual(
s.duration_formatted(),
"02:40",
)

View File

@ -83,16 +83,6 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%r seconds") result = format_duration(delta, "%r seconds")
self.assertEqual(result, "0 seconds") self.assertEqual(result, "0 seconds")
def test_specific(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%H:%m")
self.assertEqual(result, "2:40")
def test_specific_precise_if_unncessary(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%02.0H:%02.0m")
self.assertEqual(result, "02:40")
def test_all_at_once(self): def test_all_at_once(self):
delta = timedelta(days=50, hours=10, minutes=34, seconds=24) delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
result = format_duration( result = format_duration(

View File

@ -40,9 +40,9 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
] ]
if DEBUG: # if DEBUG:
INSTALLED_APPS.append("django_extensions") INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin") INSTALLED_APPS.append("django.contrib.admin")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
@ -123,7 +123,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static" STATIC_ROOT = BASE_DIR / "static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@ -150,3 +150,5 @@ if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",") CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else: else:
CSRF_TRUSTED_ORIGINS = [] CSRF_TRUSTED_ORIGINS = []
USE_L10N = False

View File

@ -23,5 +23,6 @@ urlpatterns = [
path("tracker/", include("games.urls")), path("tracker/", include("games.urls")),
] ]
if settings.DEBUG: # if settings.DEBUG:
urlpatterns.append(path("admin/", admin.site.urls)) # urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("admin/", admin.site.urls))