Compare commits
32 Commits
1.5.0
...
9481bd5fef
Author | SHA1 | Date | |
---|---|---|---|
9481bd5fef | |||
4083165123 | |||
45bb2681c7 | |||
dbb8ec3f9a | |||
206b5f6d46 | |||
b7e14ecc83 | |||
912e010729 | |||
a485237456 | |||
f5faf92ee0 | |||
07452d8c43 | |||
229a79d266 | |||
c6ed577fe3 | |||
171e4779a3 | |||
79f94e5984 | |||
ccebcb89c6 | |||
fe0a6b39e3 | |||
6a495f951f | |||
c8646d0a0c | |||
f2bb15e669 | |||
c49177d63c | |||
bd8d30eac1 | |||
c44d8bf427 | |||
3f037b4c7c | |||
8783d1fc8e | |||
9a1d24dbfd | |||
4720660cff | |||
e158bc0623 | |||
8982fc5086 | |||
729e1d939b | |||
2b4683e489 | |||
cce810e8cf | |||
62cd17f702 |
@ -5,4 +5,13 @@
|
||||
.venv
|
||||
.vscode
|
||||
node_modules
|
||||
src/timetracker/static/*
|
||||
static
|
||||
.drone.yml
|
||||
.editorconfig
|
||||
.gitignore
|
||||
Caddyfile
|
||||
CHANGELOG.md
|
||||
db.sqlite3
|
||||
docker-compose*
|
||||
Dockerfile
|
||||
Makefile
|
||||
|
@ -5,7 +5,7 @@ name: default
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: python:3.10
|
||||
image: python:3.12
|
||||
commands:
|
||||
- python -m pip install poetry
|
||||
- poetry install
|
||||
|
27
.gitea/workflows/build-docker.yml
Normal file
27
.gitea/workflows/build-docker.yml
Normal file
@ -0,0 +1,27 @@
|
||||
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
|
10
.pre-commit-config.yaml
Normal file
10
.pre-commit-config.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
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)
|
12
CHANGELOG.md
12
CHANGELOG.md
@ -1,3 +1,15 @@
|
||||
## 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
|
||||
|
46
Dockerfile
46
Dockerfile
@ -1,27 +1,45 @@
|
||||
FROM node as css
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN npm install && \
|
||||
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
|
||||
FROM python:3.12.0-slim-bullseye
|
||||
|
||||
FROM python:3.10.9-slim-bullseye
|
||||
ENV VERSION_NUMBER=1.5.1 \
|
||||
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'
|
||||
|
||||
ENV VERSION_NUMBER 1.5.0
|
||||
ENV PROD 1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
RUN apt-get update && apt-get upgrade -y \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
bash \
|
||||
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
|
||||
COPY . /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 /
|
||||
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
|
||||
ENV PATH="$PATH:/home/timetracker/.local/bin"
|
||||
RUN pip install --no-cache-dir poetry
|
||||
RUN poetry install
|
||||
|
||||
EXPOSE 8000
|
||||
CMD [ "/entrypoint.sh" ]
|
||||
|
@ -72,6 +72,10 @@ 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) {
|
||||
form input,
|
||||
select,
|
||||
|
@ -1,12 +1,5 @@
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def _safe_timedelta(duration: timedelta | int | None):
|
||||
|
@ -10,13 +10,14 @@ services:
|
||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||
user: "1000"
|
||||
volumes:
|
||||
- "static-files:/home/timetracker/app/static"
|
||||
- "static-files:/var/www/django/static"
|
||||
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: caddy
|
||||
volumes:
|
||||
- "static-files:/usr/share/caddy"
|
||||
- "static-files:/usr/share/caddy:ro"
|
||||
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@ -26,3 +27,4 @@ services:
|
||||
volumes:
|
||||
static-files:
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
|
||||
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(Game)
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
from django.urls import reverse
|
||||
|
||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
|
||||
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||
|
||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||
custom_datetime_widget = forms.DateTimeInput(
|
||||
@ -50,13 +51,31 @@ class IncludePlatformSelect(forms.Select):
|
||||
|
||||
|
||||
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(
|
||||
queryset=Edition.objects.order_by("sort_name"),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.order_by("edition__sort_name")
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
|
||||
"edition__sort_name"
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -80,6 +99,27 @@ class PurchaseForm(forms.ModelForm):
|
||||
"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):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-19 18:30
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:06
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 11:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 18:14
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def rename_duplicates(apps, schema_editor):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 16:53
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-14 08:41
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,6 +1,7 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-14 11:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from games.models import Purchase
|
||||
|
||||
|
||||
|
26
games/migrations/0029_alter_purchase_related_purchase.py
Normal file
26
games/migrations/0029_alter_purchase_related_purchase.py
Normal file
@ -0,0 +1,26 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
18
games/migrations/0030_alter_purchase_name.py
Normal file
18
games/migrations/0030_alter_purchase_name.py
Normal file
@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
@ -0,0 +1,44 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
@ -0,0 +1,52 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
@ -1,11 +1,11 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import timedelta
|
||||
|
||||
from common.time import format_duration
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Manager, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from common.time import format_duration
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
@ -13,6 +13,7 @@ class Game(models.Model):
|
||||
sort_name = models.CharField(max_length=255, 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)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -43,6 +44,7 @@ class Edition(models.Model):
|
||||
)
|
||||
year_released = models.IntegerField(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):
|
||||
return self.sort_name
|
||||
@ -120,18 +122,27 @@ class Purchase(models.Model):
|
||||
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="Unknown Name", null=True, blank=True
|
||||
)
|
||||
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
|
||||
"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):
|
||||
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()})"
|
||||
additional_info = [
|
||||
self.get_type_display() if self.type != Purchase.GAME else "",
|
||||
f"{self.edition.platform} version on {self.platform}"
|
||||
if self.platform != self.edition.platform
|
||||
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
|
||||
@ -139,12 +150,17 @@ class Purchase(models.Model):
|
||||
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):
|
||||
name = models.CharField(max_length=255)
|
||||
group = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -162,6 +178,9 @@ class SessionQuerySet(models.QuerySet):
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
class Meta:
|
||||
get_latest_by = "timestamp_start"
|
||||
|
||||
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
|
||||
timestamp_start = models.DateTimeField()
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True)
|
||||
@ -175,6 +194,8 @@ class Session(models.Model):
|
||||
default=None,
|
||||
)
|
||||
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()
|
||||
|
||||
@ -183,10 +204,10 @@ class Session(models.Model):
|
||||
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
|
||||
def finish_now(self):
|
||||
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
self.timestamp_end = timezone.now()
|
||||
|
||||
def start_now():
|
||||
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
self.timestamp_start = timezone.now()
|
||||
|
||||
def duration_seconds(self) -> timedelta:
|
||||
manual = timedelta(0)
|
||||
@ -239,6 +260,7 @@ class Device(models.Model):
|
||||
]
|
||||
name = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_type_display()})"
|
||||
|
@ -1231,6 +1231,19 @@ textarea:disabled) {
|
||||
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) {
|
||||
form input,
|
||||
select,
|
||||
@ -1429,6 +1442,10 @@ th label {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.sm\:pl-12 {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.sm\:pl-2 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
@ -1,4 +1,9 @@
|
||||
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenFalse,
|
||||
} from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
@ -11,21 +16,28 @@ let syncData = [
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
|
||||
|
||||
let myConfig = [
|
||||
() => {
|
||||
return getEl("#id_type").value == "game";
|
||||
},
|
||||
["#id_name", "#id_related_purchase"],
|
||||
(el) => {
|
||||
el.disabled = "disabled";
|
||||
},
|
||||
(el) => {
|
||||
el.disabled = "";
|
||||
}
|
||||
]
|
||||
|
||||
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
|
||||
getEl("#id_type").onchange = () => {
|
||||
conditionalElementHandler(...myConfig)
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -99,37 +99,72 @@ function getEl(selector) {
|
||||
return document.getElementsByClassName(selector)
|
||||
}
|
||||
else {
|
||||
return document.getElementsByName(selector)
|
||||
return document.getElementsByTagName(selector)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Does something to elements when something happens.
|
||||
* @param {() => boolean} condition The condition that is being tested.
|
||||
* @param {string[]} targetElements
|
||||
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches.
|
||||
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match.
|
||||
* @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(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 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler };
|
||||
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 };
|
||||
|
@ -13,7 +13,7 @@
|
||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||
</head>
|
||||
|
||||
<body class="dark">
|
||||
<body class="dark" hx-indicator="#indicator" hx-boost="true">
|
||||
<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">
|
||||
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
||||
|
@ -12,7 +12,6 @@
|
||||
id="last-session-start"
|
||||
href="{% 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-target=".responsive-table tbody"
|
||||
hx-select=".responsive-table tbody tr:first-child"
|
||||
@ -24,6 +23,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if dataset.count != 0 %}
|
||||
<table class="responsive-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -74,4 +74,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
1
games/templates/partials/related_purchase_field.html
Normal file
1
games/templates/partials/related_purchase_field.html
Normal file
@ -0,0 +1 @@
|
||||
{{ form.related_purchase }}
|
@ -13,51 +13,47 @@
|
||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||
</h1>
|
||||
<h2 class="text-lg my-2 ml-2">
|
||||
{{ total_hours }} <span class="dark:text-slate-500">total</span>
|
||||
{{ hours_sum }} <span class="dark:text-slate-500">total</span>
|
||||
{{ session_average }} <span class="dark:text-slate-500">avg</span>
|
||||
({{ playrange }}) </h2>
|
||||
<hr class="border-slate-500">
|
||||
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
|
||||
<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>
|
||||
|
||||
<ul>
|
||||
{% for edition in editions %}
|
||||
<li class="sm:pl-2 flex items-center">
|
||||
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
|
||||
{% if edition.wikidata %}
|
||||
<span class="hidden sm:inline">
|
||||
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
|
||||
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
|
||||
</a>
|
||||
</span>
|
||||
<span class="hidden sm:inline">
|
||||
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
|
||||
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% if purchase.related_purchases %}
|
||||
<li>
|
||||
<ul>
|
||||
{% for related_purchase in purchase.related_purchases %}
|
||||
<li class="sm:pl-6 flex items-center">
|
||||
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ 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>
|
||||
<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>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</ul>
|
||||
|
||||
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
|
||||
Sessions
|
||||
<span class="dark:text-slate-500">
|
||||
|
@ -44,6 +44,11 @@ urlpatterns = [
|
||||
views.add_purchase,
|
||||
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-for-game/<int:game_id>",
|
||||
|
142
games/views.py
142
games/views.py
@ -1,24 +1,31 @@
|
||||
from common.time import format_duration, now as now_with_tz
|
||||
from common.utils import safe_division
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum, F, Count
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Count, F, Prefetch, Sum
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.http import (
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseBadRequest,
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from typing import Callable, Any
|
||||
from zoneinfo import ZoneInfo
|
||||
from django.utils import timezone
|
||||
|
||||
from common.time import format_duration
|
||||
from common.utils import safe_division
|
||||
|
||||
from .forms import (
|
||||
DeviceForm,
|
||||
EditionForm,
|
||||
GameForm,
|
||||
PlatformForm,
|
||||
PurchaseForm,
|
||||
SessionForm,
|
||||
EditionForm,
|
||||
DeviceForm,
|
||||
)
|
||||
from .models import Game, Platform, Purchase, Session, Edition
|
||||
from .models import Edition, Game, Platform, Purchase, Session
|
||||
|
||||
|
||||
def model_counts(request):
|
||||
@ -32,19 +39,15 @@ def model_counts(request):
|
||||
|
||||
|
||||
def stats_dropdown_year_range(request):
|
||||
result = {
|
||||
"stats_dropdown_year_range": range(
|
||||
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
|
||||
)
|
||||
}
|
||||
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
|
||||
return result
|
||||
|
||||
|
||||
def add_session(request, purchase_id=None):
|
||||
context = {}
|
||||
initial = {"timestamp_start": now_with_tz()}
|
||||
initial = {"timestamp_start": timezone.now()}
|
||||
|
||||
last = Session.objects.all().last()
|
||||
last = Session.objects.last()
|
||||
if last != None:
|
||||
initial["purchase"] = last.purchase
|
||||
|
||||
@ -136,44 +139,52 @@ def edit_game(request, game_id=None):
|
||||
|
||||
|
||||
def view_game(request, game_id=None):
|
||||
context = {}
|
||||
game = Game.objects.get(id=game_id)
|
||||
context["title"] = "View Game"
|
||||
context["game"] = game
|
||||
context["editions"] = Edition.objects.filter(game_id=game_id)
|
||||
game_purchases = (
|
||||
Purchase.objects.filter(edition__game_id=game_id)
|
||||
.filter(type=Purchase.GAME)
|
||||
.order_by("date_purchased")
|
||||
nongame_related_purchases_prefetch = Prefetch(
|
||||
"related_purchases",
|
||||
queryset=Purchase.objects.exclude(type=Purchase.GAME),
|
||||
to_attr="nongame_related_purchases",
|
||||
)
|
||||
game_purchases_prefetch = Prefetch(
|
||||
"purchase_set",
|
||||
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")
|
||||
)
|
||||
for purchase in game_purchases:
|
||||
purchase.related_purchases = Purchase.objects.exclude(
|
||||
type=Purchase.GAME
|
||||
).filter(related_purchase=purchase.id)
|
||||
|
||||
context["purchases"] = game_purchases
|
||||
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")
|
||||
)
|
||||
context["session_average"] = round(
|
||||
(context["total_hours"]) / int(context["sessions"].count()), 1
|
||||
)
|
||||
# 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)
|
||||
session_count = sessions.count()
|
||||
|
||||
context["playrange"] = (
|
||||
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
|
||||
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
|
||||
|
||||
playrange = (
|
||||
playrange_start
|
||||
if 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
|
||||
return render(request, "view_game.html", context)
|
||||
|
||||
@ -204,17 +215,24 @@ def edit_edition(request, edition_id=None):
|
||||
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
|
||||
def start_game_session(request, game_id: int):
|
||||
last_session = (
|
||||
Session.objects.filter(purchase__edition__game_id=game_id)
|
||||
.order_by("-timestamp_start")
|
||||
.first()
|
||||
)
|
||||
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
|
||||
session = SessionForm(
|
||||
{
|
||||
"purchase": last_session.purchase.id,
|
||||
"timestamp_start": now_with_tz(),
|
||||
"timestamp_start": timezone.now(),
|
||||
"device": last_session.device,
|
||||
}
|
||||
)
|
||||
@ -227,7 +245,7 @@ def start_session_same_as_last(request, last_session_id: int):
|
||||
session = SessionForm(
|
||||
{
|
||||
"purchase": last_session.purchase.id,
|
||||
"timestamp_start": now_with_tz(),
|
||||
"timestamp_start": timezone.now(),
|
||||
"device": last_session.device,
|
||||
}
|
||||
)
|
||||
@ -269,27 +287,29 @@ def list_sessions(
|
||||
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
|
||||
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
|
||||
elif filter == "recent":
|
||||
current_year = datetime.now().year
|
||||
first_day_of_year = datetime(current_year, 1, 1)
|
||||
current_year = timezone.now().year
|
||||
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
|
||||
dataset = Session.objects.filter(
|
||||
timestamp_start__gte=first_day_of_year
|
||||
).order_by("-timestamp_start")
|
||||
context["title"] = "This year"
|
||||
else:
|
||||
# by default, sort from newest to oldest
|
||||
dataset = Session.objects.all().order_by("-timestamp_start")
|
||||
dataset = Session.objects.order_by("-timestamp_start")
|
||||
|
||||
for session in dataset:
|
||||
if session.timestamp_end == None and session.duration_manual == timedelta(
|
||||
seconds=0
|
||||
):
|
||||
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
session.timestamp_end = timezone.now()
|
||||
session.unfinished = True
|
||||
|
||||
context["total_duration"] = dataset.total_duration_formatted()
|
||||
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()
|
||||
try:
|
||||
context["last"] = Session.objects.latest()
|
||||
except ObjectDoesNotExist:
|
||||
context["last"] = None
|
||||
|
||||
return render(request, "list_sessions.html", context)
|
||||
|
||||
@ -299,7 +319,7 @@ def stats(request, year: int = 0):
|
||||
if selected_year:
|
||||
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
|
||||
if year == 0:
|
||||
year = now_with_tz().year
|
||||
year = timezone.now().year
|
||||
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
|
||||
selected_currency = "CZK"
|
||||
unique_days = (
|
||||
@ -432,7 +452,7 @@ def stats(request, year: int = 0):
|
||||
|
||||
def add_purchase(request, edition_id=None):
|
||||
context = {}
|
||||
initial = {"date_purchased": now_with_tz()}
|
||||
initial = {"date_purchased": timezone.now()}
|
||||
|
||||
if request.method == "POST":
|
||||
form = PurchaseForm(request.POST or None, initial=initial)
|
||||
|
780
poetry.lock
generated
780
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,15 @@
|
||||
[tool.poetry]
|
||||
name = "timetracker"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
description = "A simple time tracker."
|
||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
||||
license = "GPL"
|
||||
readme = "README.md"
|
||||
packages = [{include = "timetracker"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
django = "^4.1.4"
|
||||
[tool.poetry.group.main.dependencies]
|
||||
python = "^3.12"
|
||||
django = "^4.2.0"
|
||||
gunicorn = "^20.1.0"
|
||||
uvicorn = "^0.20.0"
|
||||
|
||||
@ -23,6 +23,10 @@ werkzeug = "^2.2.2"
|
||||
djhtml = "^1.5.2"
|
||||
djlint = "^1.19.11"
|
||||
isort = "^5.11.4"
|
||||
pre-commit = "^3.5.0"
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
@ -1,15 +1,16 @@
|
||||
import django
|
||||
import os
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
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 Game, Edition, Purchase, Session, Platform
|
||||
from games.models import Edition, Game, Platform, Purchase, Session
|
||||
|
||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
@ -1,14 +1,16 @@
|
||||
import django
|
||||
import os
|
||||
from django.test import TestCase
|
||||
from django.db import models
|
||||
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 Game, Edition, Purchase, Session
|
||||
|
||||
from games.models import Edition, Game, Purchase, Session
|
||||
|
||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
@ -40,9 +40,9 @@ INSTALLED_APPS = [
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
# if DEBUG:
|
||||
INSTALLED_APPS.append("django_extensions")
|
||||
INSTALLED_APPS.append("django.contrib.admin")
|
||||
if DEBUG:
|
||||
INSTALLED_APPS.append("django_extensions")
|
||||
INSTALLED_APPS.append("django.contrib.admin")
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
@ -123,7 +123,7 @@ USE_TZ = True
|
||||
# https://docs.djangoproject.com/en/4.1/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
|
||||
|
@ -23,6 +23,5 @@ urlpatterns = [
|
||||
path("tracker/", include("games.urls")),
|
||||
]
|
||||
|
||||
# if settings.DEBUG:
|
||||
# urlpatterns.append(path("admin/", admin.site.urls))
|
||||
urlpatterns.append(path("admin/", admin.site.urls))
|
||||
if settings.DEBUG:
|
||||
urlpatterns.append(path("admin/", admin.site.urls))
|
||||
|
Reference in New Issue
Block a user