47 Commits

Author SHA1 Message Date
lukas 277ecd1b55 Update to 1.6.1
Django CI/CD / test (push) Successful in 24s
Django CI/CD / build-and-push (push) Has been skipped
2026-01-30 11:49:39 +01:00
lukas 4e3a5ef682 Make buttons use pointer cursor 2026-01-30 11:45:42 +01:00
lukas 233f63f18e Update Django et al
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 1m25s
2026-01-29 16:53:45 +01:00
lukas 016f307240 Upgrade to Tailwind v4 2026-01-29 13:17:04 +01:00
lukas 715acd6244 Finish poetry migration 2026-01-29 12:56:45 +01:00
lukas 0bc48d01a7 Fix search field icon misalignment
Django CI/CD / test (push) Successful in 16s
Django CI/CD / build-and-push (push) Successful in 1m0s
2026-01-29 12:17:40 +01:00
lukas c5646d0451 Make sure Dockerfile is consistent with entrypoint.sh
Django CI/CD / test (push) Successful in 23s
Django CI/CD / build-and-push (push) Successful in 48s
2026-01-27 21:39:30 +01:00
lukas 710a0fc5bc Update entrypoint.sh
Django CI/CD / test (push) Successful in 23s
Django CI/CD / build-and-push (push) Successful in 57s
2026-01-27 21:30:04 +01:00
lukas 1d0d16b4d4 Disable cache
Django CI/CD / test (push) Successful in 21s
Django CI/CD / build-and-push (push) Successful in 49s
2026-01-27 21:15:39 +01:00
lukas 6b89bab0a6 Switch from poetry to uv
Django CI/CD / test (push) Successful in 9m34s
Django CI/CD / build-and-push (push) Failing after 1m55s
2026-01-27 20:03:39 +01:00
lukas 2bc2d98f88 Fix purchase form logic 2026-01-27 19:30:07 +01:00
lukas 06096d471e Improve dark/light mode 2026-01-27 19:28:05 +01:00
lukas 40869e25f3 Pre-calculate playevent time from last playevent 2026-01-27 18:39:09 +01:00
lukas 4f0ac21ba3 Fill up 2026-01-27 18:39:09 +01:00
lukas 3801949fdb Keep calculate_price_per_game stub
Django CI/CD / test (push) Successful in 24s
Django CI/CD / build-and-push (push) Successful in 2m1s
2026-01-16 12:32:32 +01:00
lukas f895dc1265 Prepare 1.6.0
Django CI/CD / test (push) Successful in 29s
Django CI/CD / build-and-push (push) Has been skipped
2026-01-15 23:15:03 +01:00
lukas 04601ca13d Replace game selector on game view with component 2026-01-15 23:12:54 +01:00
lukas d53575ab48 Create game status selector template 2026-01-15 23:11:13 +01:00
lukas 4e1f55855d Update history when status changes 2026-01-15 22:43:17 +01:00
lukas 95af4ceed6 Use c-gamestatus everywhere 2026-01-15 22:40:29 +01:00
lukas 6bb89438df CI: switch to checkout@v6
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 2m32s
2026-01-15 21:19:18 +01:00
lukas bd5525e57e Add verbose names to selected fields 2026-01-15 21:18:58 +01:00
lukas 5cac19be7b Fix dropdown and formatting 2026-01-15 21:18:40 +01:00
lukas a6577a9e53 Remove redundant task
price_per_game is a generated column, which means it's update
automatically
2026-01-15 17:49:30 +01:00
lukas 243830a84a Make removing games and associated purchases more robust 2026-01-15 17:49:03 +01:00
lukas 7032b8c7c7 Fix signals interfering with deleting a game with sessions
Django CI/CD / test (push) Successful in 2m29s
Django CI/CD / build-and-push (push) Failing after 1m31s
2025-12-30 13:24:09 +01:00
lukas 5cc1652002 Always set game status change timestamp to now instead of game's last updated_at
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m15s
2025-06-07 20:28:14 +02:00
lukas 7cf2180192 Allow setting game to Finished when creating PlayEvent
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-06-07 20:14:14 +02:00
lukas ad0641f95b Fix playtime stats per year
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 2m39s
2025-04-17 16:15:38 +02:00
lukas abdcfdfe64 Redirect to previous page after editing a session
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-04-01 15:36:40 +02:00
lukas 31daf2efe0 Make game overview playthrough dropdown close when clicked outside
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-03-28 13:43:46 +01:00
lukas 6d53fca910 Always return timedelta in update_game_playtime
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m19s
2025-03-26 12:05:10 +01:00
lukas f7e426e030 Make it easier to create a play event 2025-03-26 12:04:46 +01:00
lukas b29e4edd72 Continue making use of improved duration handling
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m22s
2025-03-25 23:38:04 +01:00
lukas 3c58851b88 Improve form fields for duration and currency
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m10s
2025-03-25 22:46:17 +01:00
lukas 99f3540825 Improve duration handling for sessions and games 2025-03-25 22:46:01 +01:00
lukas 5e778bec30 Fix stats having hardcoded year
Django CI/CD / test (push) Successful in 1m20s
Django CI/CD / build-and-push (push) Successful in 2m35s
2025-03-25 15:56:20 +01:00
lukas fea9d9784d Fix purchase-name partial
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-03-24 21:02:13 +01:00
lukas 23b4a7a069 Make it possible to edit and delete status changes
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 2m18s
2025-03-22 23:45:02 +01:00
lukas 89de85c00d Introduce game status, playevents
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-03-22 20:59:23 +01:00
lukas d892659132 Add debugging config 2025-03-22 20:58:30 +01:00
lukas 341e62283b Change gamestatus to non-block element 2025-03-22 20:54:20 +01:00
lukas 61b6c1c55f Set default logger to WARNING, add games handler at INFO 2025-03-22 20:52:39 +01:00
lukas eeaa02bada Add helper scripts for getting exchange rates 2025-03-22 20:52:02 +01:00
lukas 9d16bc2546 Update exchangerates.yaml 2025-03-22 20:49:21 +01:00
lukas 7a52b59b3d Improve logging in tasks.py
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 2m9s
2025-03-22 16:57:27 +01:00
lukas 0ce59a8cc6 Fix a bug in convert_prices
Prevents actually finding any new prices
2025-03-22 16:57:27 +01:00
73 changed files with 6606 additions and 5057 deletions
+26 -12
View File
@@ -9,28 +9,42 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with: with:
python-version: 3.12 enable-cache: false
- run: | python-version: "3.14"
python -m pip install poetry
poetry install - name: Install dependencies
poetry env info run: uv sync --frozen
poetry run python manage.py migrate
# PROD=1 poetry run pytest - name: Run Migrations
run: uv run python manage.py migrate
# - name: Run Tests
# run: PROD=1 uv run pytest
build-and-push: build-and-push:
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5 - name: Set Version
run: echo "VERSION_NUMBER=1.6.1" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
tags: | tags: |
registry.kucharczyk.xyz/timetracker:latest registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }} registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env: # cache-from: type=gha
VERSION_NUMBER: 1.5.1 # cache-to: type=gha,mode=max
+26
View File
@@ -0,0 +1,26 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
},
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"args": [
"runserver"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/manage.py"
}
]
}
+13 -4
View File
@@ -1,6 +1,15 @@
## Unreleased ## 1.6.1 / 2026-01-30 11:48+01:00
## New ### New
* Pre-fill time played into new playevent, also tracks time since last playevent
* Improve light theme and fix light/dark theme switcher
* Fix purchase form logic
* Update dependencies
## 1.6.0 / 2025-01-15 23:13+01:00
### New
* Visual overhaul of many pages
* Render notes as Markdown * Render notes as Markdown
* Require login by default * Require login by default
* Add stats for dropped purchases, monthly playtimes * Add stats for dropped purchases, monthly playtimes
@@ -11,7 +20,7 @@
* Add emulated property to sessions * Add emulated property to sessions
* Add today's and last 7 days playtime stats to navbar * Add today's and last 7 days playtime stats to navbar
## Improved ### Improved
* mark refunded purchases red on game overview * mark refunded purchases red on game overview
* increase session count on game overview when starting a new session * increase session count on game overview when starting a new session
* game overview: * game overview:
@@ -22,7 +31,7 @@
* session list: use display name instead of sort name * session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover * unify the appearance of game links, and make them expand to full size on hover
## Fixed ### Fixed
* Fix title not being displayed on the Recent sessions page * Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions * Avoid errors when displaying game overview with zero sessions
+31 -35
View File
@@ -1,45 +1,41 @@
FROM python:3.12.0-slim-bullseye FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
ENV VERSION_NUMBER=1.5.2 \ ENV UV_LINK_MODE=copy \
PROD=1 \ UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /home/timetracker/app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM python:3.14-slim-bookworm
ENV PROD=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \ PATH="/home/timetracker/app/.venv/bin:$PATH"
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 \
&& 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' \ && mkdir -p /var/www/django/static \
&& chown timetracker:timetracker '/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 entrypoint.sh /
RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \ WORKDIR /home/timetracker/app
echo "$PROD" \
&& poetry version \ COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync COPY --chown=timetracker:timetracker entrypoint.sh /
RUN chmod +x /entrypoint.sh
USER timetracker USER timetracker
ENV VERSION_NUMBER=1.6.1
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]
+26 -22
View File
@@ -9,64 +9,68 @@ npm:
npm install npm install
css: common/input.css css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
makemigrations: makemigrations:
poetry run python manage.py makemigrations uv run python manage.py makemigrations
migrate: makemigrations migrate: makemigrations
poetry run python manage.py migrate uv run python manage.py migrate
init: init:
pyenv install -s $(PYTHON_VERSION) uv install $(PYTHON_VERSION)
pyenv local $(PYTHON_VERSION) uv sync
pip install poetry
poetry install
npm install npm install
$(MAKE) sethookdir
$(MAKE) loadplatforms
sethookdir:
git config core.hooksPath .githooks
chmod +x .githooks/*
dev: dev:
@npx concurrently \ @npx concurrently \
--names "Django,Tailwind" \ --names "Django,Tailwind" \
--prefix-colors "blue,green" \ --prefix-colors "blue,green" \
"poetry run python -Wa manage.py runserver" \ "uv run python -Wa manage.py runserver" \
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" "npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
caddy: caddy:
caddy run --watch caddy run --watch
dev-prod: migrate collectstatic dev-prod: migrate collectstatic
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
dumpgames: dumpgames:
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
loadplatforms: loadplatforms:
poetry run python manage.py loaddata platforms.yaml uv run python manage.py loaddata platforms.yaml
loadall: loadall:
poetry run python manage.py loaddata data.yaml uv run python manage.py loaddata data.yaml
loadsample: loadsample:
poetry run python manage.py loaddata sample.yaml uv run python manage.py loaddata sample.yaml
createsuperuser: createsuperuser:
poetry run python manage.py createsuperuser uv run python manage.py createsuperuser
shell: shell:
poetry run python manage.py shell uv run python manage.py shell
collectstatic: collectstatic:
poetry run python manage.py collectstatic --clear --no-input uv run python manage.py collectstatic --clear --no-input
poetry.lock: pyproject.toml uv.lock: pyproject.toml
poetry install uv sync
test: poetry.lock test: uv.lock
poetry run pytest uv run pytest
date: date:
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
cleanstatic: cleanstatic:
rm -r static/* rm -r static/*
+7 -1
View File
@@ -142,7 +142,13 @@ def Button(
): ):
return Component( return Component(
template="cotton/button.html", template="cotton/button.html",
attributes=attributes + [("size", size), ("icon", icon), ("color", color)], attributes=attributes
+ [
("size", size),
("icon", icon),
("color", color),
("class", "hover:cursor-pointer"),
],
children=children, children=children,
) )
+111 -66
View File
@@ -1,92 +1,137 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities;
@font-face { @plugin '@tailwindcss/typography';
font-family: "IBM Plex Mono"; @plugin '@tailwindcss/forms';
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2"); @plugin 'flowbite/plugin';
font-weight: 400;
font-style: normal; @source '../node_modules/flowbite/**/*.js';
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono:
IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--font-serif:
IBM Plex Serif, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-condensed:
IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-accent: #7c3aed;
--color-background: #1f2937;
} }
@font-face { /*
font-family: "IBM Plex Sans"; The default border color has changed to `currentcolor` in Tailwind CSS v4,
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2"); so we've added these compatibility styles to make sure everything still
font-weight: 400; looks the same as it did with Tailwind CSS v3.
font-style: normal;
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
} }
@font-face { @utility min-w-20char {
font-family: "IBM Plex Serif"; min-width: 20ch;
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
} }
@font-face { @utility max-w-20char {
font-family: "IBM Plex Serif"; max-width: 20ch;
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
} }
@font-face { @utility min-w-30char {
font-family: "IBM Plex Sans Condensed"; min-width: 30ch;
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
} }
@utility max-w-30char {
max-width: 30ch;
}
/* a:hover { @utility max-w-35char {
max-width: 35ch;
}
@utility max-w-40char {
max-width: 40ch;
}
@layer utilities {
@font-face {
font-family: 'IBM Plex Mono';
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
/* a:hover {
text-decoration-color: #ff4400; text-decoration-color: #ff4400;
color: rgb(254, 185, 160); color: rgb(254, 185, 160);
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} */ } */
/* form label { /* form label {
@apply dark:text-slate-400; @apply dark:text-slate-400;
} */ } */
.responsive-table { .responsive-table {
@apply dark:text-white mx-auto table-fixed; @apply dark:text-white mx-auto table-fixed;
}
.responsive-table tr:nth-child(even) {
@apply bg-slate-800
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
@layer utilities {
.min-w-20char {
min-width: 20ch;
} }
.max-w-20char {
max-width: 20ch; .responsive-table tr:nth-child(even) {
@apply bg-indigo-100 dark:bg-slate-800;
} }
.min-w-30char {
min-width: 30ch; .responsive-table tbody tr:nth-child(odd) {
@apply bg-indigo-200 dark:bg-slate-900;
} }
.max-w-30char {
max-width: 30ch; .responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
} }
.max-w-35char {
max-width: 35ch; .responsive-table thead th:not(:first-child),
} .responsive-table td:not(:first-child) {
.max-w-40char { @apply border-l border-l-slate-500;
max-width: 40ch;
} }
} }
@@ -131,7 +176,7 @@ textarea:disabled {
} }
.basic-button { .basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out; @apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded-sm shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-hidden focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
} }
.markdown-content ul { .markdown-content ul {
+1 -1
View File
@@ -13,7 +13,7 @@ durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):
if duration == None: if duration is None:
return timedelta(0) return timedelta(0)
elif isinstance(duration, int): elif isinstance(duration, int):
return timedelta(seconds=duration) return timedelta(seconds=duration)
+41 -4
View File
@@ -1,10 +1,14 @@
import operator import operator
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from functools import reduce from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
def safe_division(numerator: int | float, denominator: int | float) -> int | float: def safe_division(numerator: int | float, denominator: int | float) -> int | float:
""" """
@@ -40,7 +44,7 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str: def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return ( return (
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}") (f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length if len(input_string) > length
else input_string else input_string
) )
@@ -54,12 +58,12 @@ def truncate(
raise ValueError("Length cannot be shorter than the length of endpart.") raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length: if len(input_string) > max_content_length:
return f"{input_string[:max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}" return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return ( return (
f"{input_string}{endpart}" f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length if len(input_string) + len(endpart) <= length
else f"{input_string[:length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}" else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
) )
@@ -128,3 +132,36 @@ def build_dynamic_filter(
processed_filters, processed_filters,
Q(), Q(),
) )
def redirect_to(default_view: str, *default_args):
"""
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
:param default_view: The name of the default view to redirect to if 'next' is missing.
:param default_args: Any arguments required for the default view.
"""
def decorator(view_func):
@wraps(view_func)
def wrapped_view(request: HttpRequest, *args, **kwargs):
next_url = request.GET.get("next")
if not next_url:
from django.urls import (
reverse, # Import inside function to avoid circular imports
)
next_url = reverse(default_view, args=default_args)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
return redirect(next_url)
return wrapped_view
return decorator
def add_next_param_to_url(url: str, nexturl: str) -> str:
return f"{url}?{urlencode({'next': nexturl})}"
@@ -0,0 +1,33 @@
from datetime import datetime
import requests
url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json"
date_format = "%Y%m%d"
years = range(2000, datetime.now().year + 1)
dates = [
datetime.strftime(datetime(day=1, month=1, year=year), format=date_format)
for year in years
]
for date in dates:
final_url = url.format(date)
year = date[:4]
response = requests.get(final_url)
response.raise_for_status()
data = response.json()
if kurzy := data.get("kurzy"):
with open("output.yaml", mode="a") as o:
rates = [
f"""
- model: games.exchangerate
fields:
currency_from: {currency_name}
currency_to: CZK
year: {year}
rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)}
"""
for currency_name in ["EUR", "USD", "CNY"]
if kurzy.get(currency_name)
]
o.writelines(rates)
# time.sleep(0.5)
+65
View File
@@ -0,0 +1,65 @@
import sys
import yaml
def load_yaml(filename):
with open(filename, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or []
def save_yaml(filename, data):
with open(filename, "w", encoding="utf-8") as file:
yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False)
def extract_existing_combinations(data):
return {
(
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
for entry in data
if entry["model"] == "games.exchangerate"
}
def filter_new_entries(existing_combinations, additional_files):
new_entries = []
for filename in additional_files:
data = load_yaml(filename)
for entry in data:
if entry["model"] == "games.exchangerate":
key = (
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
if key not in existing_combinations:
new_entries.append(entry)
return new_entries
def main():
if len(sys.argv) < 3:
print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]")
sys.exit(1)
example_file = sys.argv[1]
additional_files = sys.argv[2:]
output_file = "filtered_output.yaml"
existing_data = load_yaml(example_file)
existing_combinations = extract_existing_combinations(existing_data)
new_entries = filter_new_entries(existing_combinations, additional_files)
save_yaml(output_file, new_entries)
print(f"Filtered data saved to {output_file}")
if __name__ == "__main__":
main()
+4 -4
View File
@@ -2,10 +2,10 @@
# Apply database migrations # Apply database migrations
set -euo pipefail set -euo pipefail
echo "Apply database migrations" echo "Apply database migrations"
poetry run python manage.py migrate python manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input python manage.py collectstatic --clear --no-input
_term() { _term() {
echo "Caught SIGTERM signal!" echo "Caught SIGTERM signal!"
@@ -15,9 +15,9 @@ _term() {
trap _term SIGTERM trap _term SIGTERM
echo "Starting Django-Q cluster" echo "Starting Django-Q cluster"
poetry run python manage.py qcluster & django_q_pid=$! python manage.py qcluster & django_q_pid=$!
echo "Starting app" echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$! python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid" "$django_q_pid" wait "$gunicorn_pid" "$django_q_pid"
+95
View File
@@ -0,0 +1,95 @@
from datetime import date, datetime
from typing import List
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
from games.models import Game, PlayEvent
api = NinjaAPI()
playevent_router = Router()
game_router = Router()
NOW_FACTORY = django_timezone_now
class GameStatusUpdate(Schema):
status: str
class PlayEventIn(Schema):
game_id: int
started: date | None = None
ended: date | None = None
note: str = ""
days_to_finish: int | None = None
class AutoPlayEventIn(ModelSchema):
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note"]
class UpdatePlayEventIn(Schema):
started: date | None = None
ended: date | None = None
note: str = ""
class PlayEventOut(Schema):
id: int
game: str = Field(..., alias="game.name")
started: date | None = None
ended: date | None = None
days_to_finish: int | None = None
note: str = ""
updated_at: datetime
created_at: datetime
@game_router.patch("/{game_id}/status", response={204: None})
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
game = get_object_or_404(Game, id=game_id)
setattr(game, "status", payload.status)
game.save()
return 204, None
@playevent_router.get("/", response=List[PlayEventOut])
def list_playevents(request):
return PlayEvent.objects.all()
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
return playevent
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
def get_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
return playevent
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(playevent, attr, value)
playevent.save()
return playevent
@playevent_router.delete("/{playevent_id}", response={204: None})
def delete_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return 204, None
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router)
+21 -20
View File
@@ -1,9 +1,10 @@
from datetime import timedelta # from datetime import timedelta
from django.apps import AppConfig from django.apps import AppConfig
from django.core.management import call_command from django.core.management import call_command
from django.db.models.signals import post_migrate from django.db.models.signals import post_migrate
from django.utils.timezone import now
# from django.utils.timezone import now
class GamesConfig(AppConfig): class GamesConfig(AppConfig):
@@ -17,26 +18,26 @@ class GamesConfig(AppConfig):
def schedule_tasks(sender, **kwargs): def schedule_tasks(sender, **kwargs):
from django_q.models import Schedule # from django_q.models import Schedule
from django_q.tasks import schedule # from django_q.tasks import schedule
if not Schedule.objects.filter(name="Update converted prices").exists(): # if not Schedule.objects.filter(name="Update converted prices").exists():
schedule( # schedule(
"games.tasks.convert_prices", # "games.tasks.convert_prices",
name="Update converted prices", # name="Update converted prices",
schedule_type=Schedule.MINUTES, # schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30), # next_run=now() + timedelta(seconds=30),
catchup=False, # catchup=False,
) # )
if not Schedule.objects.filter(name="Update price per game").exists(): # if not Schedule.objects.filter(name="Update price per game").exists():
schedule( # schedule(
"games.tasks.calculate_price_per_game", # "games.tasks.calculate_price_per_game",
name="Update price per game", # name="Update price per game",
schedule_type=Schedule.MINUTES, # schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30), # next_run=now() + timedelta(seconds=30),
catchup=False, # catchup=False,
) # )
from games.models import ExchangeRate from games.models import ExchangeRate
+392
View File
@@ -110,3 +110,395 @@
currency_to: CZK currency_to: CZK
year: 2018 year: 2018
rate: 3.268 rate: 3.268
- model: games.exchangerate
pk: 17
fields:
currency_from: CNY
currency_to: CZK
year: 2023
rate: 3.281
- model: games.exchangerate
pk: 18
fields:
currency_from: EUR
currency_to: CZK
year: 2009
rate: 26.445
- model: games.exchangerate
pk: 19
fields:
currency_from: CNY
currency_to: CZK
year: 2025
rate: 3.35
- model: games.exchangerate
pk: 20
fields:
currency_from: EUR
currency_to: CZK
year: 2016
rate: 27.033
- model: games.exchangerate
pk: 21
fields:
currency_from: EUR
currency_to: CZK
year: 2025
rate: 25.2021966
- model: games.exchangerate
pk: 22
fields:
currency_from: EUR
currency_to: CZK
year: 2017
rate: 26.33
- model: games.exchangerate
pk: 23
fields:
currency_from: EUR
currency_to: CZK
year: 2000
rate: 36.13
- model: games.exchangerate
pk: 24
fields:
currency_from: USD
currency_to: CZK
year: 2000
rate: 35.979
- model: games.exchangerate
pk: 25
fields:
currency_from: EUR
currency_to: CZK
year: 2001
rate: 35.09
- model: games.exchangerate
pk: 26
fields:
currency_from: USD
currency_to: CZK
year: 2001
rate: 37.813
- model: games.exchangerate
pk: 27
fields:
currency_from: EUR
currency_to: CZK
year: 2002
rate: 31.98
- model: games.exchangerate
pk: 28
fields:
currency_from: USD
currency_to: CZK
year: 2002
rate: 36.259
- model: games.exchangerate
pk: 29
fields:
currency_from: EUR
currency_to: CZK
year: 2003
rate: 31.6
- model: games.exchangerate
pk: 30
fields:
currency_from: USD
currency_to: CZK
year: 2003
rate: 30.141
- model: games.exchangerate
pk: 31
fields:
currency_from: EUR
currency_to: CZK
year: 2004
rate: 32.405
- model: games.exchangerate
pk: 32
fields:
currency_from: USD
currency_to: CZK
year: 2004
rate: 25.654
- model: games.exchangerate
pk: 33
fields:
currency_from: EUR
currency_to: CZK
year: 2005
rate: 30.465
- model: games.exchangerate
pk: 34
fields:
currency_from: USD
currency_to: CZK
year: 2005
rate: 22.365
- model: games.exchangerate
pk: 35
fields:
currency_from: EUR
currency_to: CZK
year: 2006
rate: 29.005
- model: games.exchangerate
pk: 36
fields:
currency_from: USD
currency_to: CZK
year: 2006
rate: 24.588
- model: games.exchangerate
pk: 37
fields:
currency_from: CNY
currency_to: CZK
year: 2006
rate: 3.047
- model: games.exchangerate
pk: 38
fields:
currency_from: EUR
currency_to: CZK
year: 2007
rate: 27.495
- model: games.exchangerate
pk: 39
fields:
currency_from: USD
currency_to: CZK
year: 2007
rate: 20.876
- model: games.exchangerate
pk: 40
fields:
currency_from: CNY
currency_to: CZK
year: 2007
rate: 2.674
- model: games.exchangerate
pk: 41
fields:
currency_from: EUR
currency_to: CZK
year: 2008
rate: 26.62
- model: games.exchangerate
pk: 42
fields:
currency_from: USD
currency_to: CZK
year: 2008
rate: 18.078
- model: games.exchangerate
pk: 43
fields:
currency_from: CNY
currency_to: CZK
year: 2008
rate: 2.475
- model: games.exchangerate
pk: 44
fields:
currency_from: USD
currency_to: CZK
year: 2009
rate: 19.346
- model: games.exchangerate
pk: 45
fields:
currency_from: CNY
currency_to: CZK
year: 2009
rate: 2.836
- model: games.exchangerate
pk: 46
fields:
currency_from: USD
currency_to: CZK
year: 2010
rate: 18.368
- model: games.exchangerate
pk: 47
fields:
currency_from: CNY
currency_to: CZK
year: 2010
rate: 2.691
- model: games.exchangerate
pk: 48
fields:
currency_from: EUR
currency_to: CZK
year: 2011
rate: 25.06
- model: games.exchangerate
pk: 49
fields:
currency_from: USD
currency_to: CZK
year: 2011
rate: 18.751
- model: games.exchangerate
pk: 50
fields:
currency_from: CNY
currency_to: CZK
year: 2011
rate: 2.845
- model: games.exchangerate
pk: 51
fields:
currency_from: USD
currency_to: CZK
year: 2012
rate: 19.94
- model: games.exchangerate
pk: 52
fields:
currency_from: CNY
currency_to: CZK
year: 2012
rate: 3.168
- model: games.exchangerate
pk: 53
fields:
currency_from: EUR
currency_to: CZK
year: 2013
rate: 25.14
- model: games.exchangerate
pk: 54
fields:
currency_from: CNY
currency_to: CZK
year: 2013
rate: 3.059
- model: games.exchangerate
pk: 55
fields:
currency_from: USD
currency_to: CZK
year: 2014
rate: 19.894
- model: games.exchangerate
pk: 56
fields:
currency_from: CNY
currency_to: CZK
year: 2014
rate: 3.286
- model: games.exchangerate
pk: 57
fields:
currency_from: EUR
currency_to: CZK
year: 2015
rate: 27.725
- model: games.exchangerate
pk: 58
fields:
currency_from: USD
currency_to: CZK
year: 2015
rate: 22.834
- model: games.exchangerate
pk: 59
fields:
currency_from: USD
currency_to: CZK
year: 2016
rate: 24.824
- model: games.exchangerate
pk: 60
fields:
currency_from: CNY
currency_to: CZK
year: 2017
rate: 3.693
- model: games.exchangerate
pk: 61
fields:
currency_from: EUR
currency_to: CZK
year: 2018
rate: 25.54
- model: games.exchangerate
pk: 62
fields:
currency_from: USD
currency_to: CZK
year: 2018
rate: 21.291
- model: games.exchangerate
pk: 63
fields:
currency_from: EUR
currency_to: CZK
year: 2019
rate: 25.725
- model: games.exchangerate
pk: 64
fields:
currency_from: EUR
currency_to: CZK
year: 2020
rate: 25.41
- model: games.exchangerate
pk: 65
fields:
currency_from: USD
currency_to: CZK
year: 2020
rate: 22.621
- model: games.exchangerate
pk: 66
fields:
currency_from: EUR
currency_to: CZK
year: 2021
rate: 26.245
- model: games.exchangerate
pk: 67
fields:
currency_from: USD
currency_to: CZK
year: 2021
rate: 21.387
- model: games.exchangerate
pk: 68
fields:
currency_from: CNY
currency_to: CZK
year: 2021
rate: 3.273
- model: games.exchangerate
pk: 69
fields:
currency_from: USD
currency_to: CZK
year: 2022
rate: 21.951
- model: games.exchangerate
pk: 70
fields:
currency_from: CNY
currency_to: CZK
year: 2022
rate: 3.458
- model: games.exchangerate
pk: 71
fields:
currency_from: EUR
currency_to: CZK
year: 2023
rate: 24.115
- model: games.exchangerate
pk: 72
fields:
currency_from: USD
currency_to: CZK
year: 2025
rate: 24.237
+75 -5
View File
@@ -1,8 +1,17 @@
from django import forms from django import forms
from django.db import transaction
from django.urls import reverse from django.urls import reverse
from common.utils import safe_getattr from common.utils import safe_getattr
from games.models import Device, Game, Platform, Purchase, Session from games.models import (
Device,
Game,
GameStatusChange,
Platform,
PlayEvent,
Purchase,
Session,
)
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(
@@ -27,6 +36,13 @@ class SessionForm(forms.ModelForm):
widget=forms.Select(attrs={"autofocus": "autofocus"}), widget=forms.Select(attrs={"autofocus": "autofocus"}),
) )
duration_manual = forms.DurationField(
required=False,
widget=forms.TextInput(
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name")) device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
mark_as_played = forms.BooleanField( mark_as_played = forms.BooleanField(
@@ -99,12 +115,22 @@ class PurchaseForm(forms.ModelForm):
required=False, required=False,
) )
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
),
label="Currency",
)
class Meta: class Meta:
widgets = { widgets = {
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
@@ -112,8 +138,6 @@ class PurchaseForm(forms.ModelForm):
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished",
"date_dropped",
"infinite", "infinite",
"price", "price",
"price_currency", "price_currency",
@@ -171,6 +195,7 @@ class GameForm(forms.ModelForm):
"name", "name",
"sort_name", "sort_name",
"platform", "platform",
"original_year_released",
"year_released", "year_released",
"status", "status",
"mastered", "mastered",
@@ -195,3 +220,48 @@ class DeviceForm(forms.ModelForm):
model = Device model = Device
fields = ["name", "type"] fields = ["name", "type"]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
mark_as_finished = forms.BooleanField(
required=False,
initial={"mark_as_finished": True},
label="Set game status to Finished",
)
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note", "mark_as_finished"]
widgets = {
"started": custom_date_widget,
"ended": custom_date_widget,
}
def save(self, commit=True):
with transaction.atomic():
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_finished"):
game_instance = session.game
game_instance.status = "f"
game_instance.save()
session.save()
return session
class GameStatusChangeForm(forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
"game",
"old_status",
"new_status",
"timestamp",
]
widgets = {
"timestamp": custom_datetime_widget,
}
@@ -0,0 +1,190 @@
# Generated by Django 5.1.7 on 2025-03-19 13:11
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
from django.db.models import F, Min
def copy_year_released(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.update(original_year_released=F("year_released"))
def set_abandoned_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game = apps.get_model("games", "Game")
PlayEvent = apps.get_model("games", "PlayEvent")
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
finished = Game.objects.filter(purchases__date_finished__isnull=False)
for game in finished:
for purchase in game.purchases.all():
first_session = game.sessions.filter(
timestamp_start__gte=purchase.date_purchased
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
first_session_date = first_session.date() if first_session else None
if purchase.date_finished:
play_event = PlayEvent(
game=game,
started=first_session_date
if first_session_date
else purchase.date_purchased,
ended=purchase.date_finished,
)
play_event.save()
def create_game_status_changes(apps, schema_editor):
Game = apps.get_model("games", "Game")
GameStatusChange = apps.get_model("games", "GameStatusChange")
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
for game in Game.objects.filter(sessions__isnull=False).distinct():
if game.sessions.exists():
earliest_session = game.sessions.earliest()
GameStatusChange.objects.create(
game=game,
old_status="u",
new_status="p",
timestamp=earliest_session.timestamp_start,
)
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_dropped,
)
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_refunded,
)
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
# consider only the first playevent
for game in Game.objects.filter(playevents__isnull=False):
first_playevent = game.playevents.first()
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="f",
timestamp=first_playevent.ended,
)
class Migration(migrations.Migration):
dependencies = [
("games", "0007_game_updated_at"),
]
operations = [
migrations.AddField(
model_name="game",
name="original_year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(copy_year_released),
migrations.CreateModel(
name="GameStatusChange",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"old_status",
models.CharField(
blank=True,
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
null=True,
),
),
(
"new_status",
models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
),
),
("timestamp", models.DateTimeField(null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="status_changes",
to="games.game",
),
),
],
options={
"ordering": ["-timestamp"],
},
),
migrations.CreateModel(
name="PlayEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("started", models.DateField(blank=True, null=True)),
("ended", models.DateField(blank=True, null=True)),
(
"days_to_finish",
models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.RawSQL(
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
[],
),
output_field=models.IntegerField(),
),
),
("note", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="playevents",
to="games.game",
),
),
],
),
migrations.RunPython(set_abandoned_status),
migrations.RunPython(create_game_status_changes),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.1.7 on 2025-03-20 11:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='date_dropped',
),
migrations.RemoveField(
model_name='purchase',
name='date_finished',
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='price_per_game',
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0010_remove_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
),
]
@@ -0,0 +1,32 @@
# Generated by Django 5.1.7 on 2025-03-25 20:30
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_purchase_price_per_game"),
]
operations = [
migrations.RemoveField(
model_name="session",
name="duration_calculated",
),
migrations.AddField(
model_name="session",
name="duration_calculated",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.functions.comparison.Coalesce(
django.db.models.expressions.CombinedExpression(
models.F("timestamp_end"), "-", models.F("timestamp_start")
),
0,
),
output_field=models.DurationField(),
),
),
]
+35
View File
@@ -0,0 +1,35 @@
# Generated by Django 5.1.7 on 2025-03-25 20:33
import datetime
from django.db import migrations, models
from django.db.models import F, Sum
def calculate_game_playtime(apps, schema_editor):
Game = apps.get_model("games", "Game")
games = Game.objects.all()
for game in games:
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_total"))
)["total_playtime"]
if total_playtime:
game.playtime = total_playtime
game.save(update_fields=["playtime"])
class Migration(migrations.Migration):
dependencies = [
("games", "0012_alter_session_duration_calculated"),
]
operations = [
migrations.AddField(
model_name="game",
name="playtime",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), editable=False
),
),
migrations.RunPython(calculate_game_playtime),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.7 on 2025-03-25 20:46
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0013_game_playtime'),
]
operations = [
migrations.AddField(
model_name='session',
name='duration_total',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
),
]
@@ -0,0 +1,39 @@
# Generated by Django 5.1.7 on 2026-01-15 15:37
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0014_session_duration_total'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='date_purchased',
field=models.DateField(verbose_name='Purchased'),
),
migrations.AlterField(
model_name='purchase',
name='date_refunded',
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
),
migrations.AlterField(
model_name='session',
name='duration_manual',
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
),
migrations.AlterField(
model_name='session',
name='timestamp_end',
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
),
migrations.AlterField(
model_name='session',
name='timestamp_start',
field=models.DateTimeField(verbose_name='Start'),
),
]
+168 -38
View File
@@ -1,13 +1,20 @@
import logging
from datetime import timedelta from datetime import timedelta
import requests
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Sum from django.db.models import F, Sum
from django.db.models.expressions import RawSQL
from django.db.models.fields.generated import GeneratedField
from django.db.models.functions import Coalesce
from django.template.defaultfilters import floatformat, pluralize, slugify from django.template.defaultfilters import floatformat, pluralize, slugify
from django.utils import timezone from django.utils import timezone
from common.time import format_duration from common.time import format_duration
logger = logging.getLogger("games")
class Game(models.Model): class Game(models.Model):
class Meta: class Meta:
@@ -16,11 +23,14 @@ class Game(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, blank=True, default="") sort_name = models.CharField(max_length=255, blank=True, default="")
year_released = models.IntegerField(null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None)
original_year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, blank=True, default="") wikidata = models.CharField(max_length=50, blank=True, default="")
platform = models.ForeignKey( platform = models.ForeignKey(
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None "Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
) )
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -55,6 +65,24 @@ class Game(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def finished(self):
return self.status == self.Status.FINISHED
def abandoned(self):
return self.status == self.Status.ABANDONED
def retired(self):
return self.status == self.Status.RETIRED
def played(self):
return self.status == self.Status.PLAYED
def unplayed(self):
return self.status == self.Status.UNPLAYED
def playtime_formatted(self):
return format_duration(self.playtime, "%2.1H")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.platform is None: if self.platform is None:
self.platform = get_sentinel_platform() self.platform = get_sentinel_platform()
@@ -89,9 +117,6 @@ class PurchaseQueryset(models.QuerySet):
def not_refunded(self): def not_refunded(self):
return self.filter(date_refunded__isnull=True) return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self): def games_only(self):
return self.filter(type=Purchase.GAME) return self.filter(type=Purchase.GAME)
@@ -133,16 +158,19 @@ class Purchase(models.Model):
platform = models.ForeignKey( platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
) )
date_purchased = models.DateField() date_purchased = models.DateField(verbose_name="Purchased")
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False) infinite = models.BooleanField(default=False)
price = models.FloatField(default=0) price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True) converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, blank=True, default="") converted_currency = models.CharField(max_length=3, blank=True, default="")
price_per_game = models.FloatField(null=True) price_per_game = GeneratedField(
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
output_field=models.FloatField(),
db_persist=True,
editable=False,
)
num_purchases = models.IntegerField(default=0) num_purchases = models.IntegerField(default=0)
ownership_type = models.CharField( ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
@@ -198,6 +226,12 @@ class Purchase(models.Model):
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
def price_or_currency_differ_from(self, purchase_to_compare):
return (
self.price != purchase_to_compare.price
or self.price_currency != purchase_to_compare.price_currency
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase: if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError( raise ValidationError(
@@ -207,12 +241,15 @@ class Purchase(models.Model):
# Retrieve the existing instance from the database # Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk) existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields # If price has changed, reset converted fields
if ( if existing_purchase.price_or_currency_differ_from(self):
existing_purchase.price != self.price from games.tasks import currency_to
or existing_purchase.price_currency != self.price_currency
): exchange_rate = get_or_create_rate(
self.converted_price = None self.price_currency, currency_to, self.date_purchased.year
self.converted_currency = None )
if exchange_rate:
self.converted_price = floatformat(self.price * exchange_rate, 0)
self.converted_currency = currency_to
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -251,10 +288,23 @@ class Session(models.Model):
default=None, default=None,
related_name="sessions", related_name="sessions",
) )
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField(verbose_name="Start")
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(
duration_calculated = models.DurationField(blank=True, null=True) blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
)
duration_calculated = GeneratedField(
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
duration_total = GeneratedField(
expression=F("duration_calculated") + F("duration_manual"),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
device = models.ForeignKey( device = models.ForeignKey(
"Device", "Device",
on_delete=models.SET_DEFAULT, on_delete=models.SET_DEFAULT,
@@ -271,7 +321,7 @@ class Session(models.Model):
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.is_manual() else "" mark = "*" if self.is_manual() else ""
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
@@ -280,32 +330,18 @@ class Session(models.Model):
def start_now(): def start_now():
self.timestamp_start = timezone.now() self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if self.is_manual() and isinstance(self.duration_manual, timedelta):
manual = self.duration_manual
if self.timestamp_end is not None and self.timestamp_start is not None:
calculated = self.timestamp_end - self.timestamp_start
return timedelta(seconds=(manual + calculated).total_seconds())
def duration_formatted(self) -> str: def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") result = format_duration(self.duration_total, "%02.1H")
return result return result
def duration_formatted_with_mark(self) -> str:
mark = "*" if self.is_manual() else ""
return f"{self.duration_formatted()}{mark}"
def is_manual(self) -> bool: def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0) return not self.duration_manual == timedelta(0)
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None:
if self.timestamp_start is not None and self.timestamp_end is not None:
self.duration_calculated = self.timestamp_end - self.timestamp_start
else:
self.duration_calculated = timedelta(0)
if not isinstance(self.duration_manual, timedelta): if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0) self.duration_manual = timedelta(0)
@@ -351,3 +387,97 @@ class ExchangeRate(models.Model):
def __str__(self): def __str__(self):
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})" return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
exchange_rate = None
result = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
)
if result:
exchange_rate = result[0].rate
else:
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
exchange_rate = exchange_rate.rate
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
return exchange_rate
class PlayEvent(models.Model):
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
started = models.DateField(null=True, blank=True)
ended = models.DateField(null=True, blank=True)
days_to_finish = GeneratedField(
# special cases:
# missing ended, started, or both = 0
# same day = 1 day to finish
expression=RawSQL(
"""
COALESCE(
CASE
WHEN date(ended) = date(started) THEN 1
ELSE julianday(ended) - julianday(started)
END, 0
)
""",
[],
),
output_field=models.IntegerField(),
db_persist=True,
editable=False,
blank=True,
)
note = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# class PlayMarker(models.Model):
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
# played_since = models.DurationField()
# played_total = models.DurationField()
# note = models.CharField(max_length=255)
class GameStatusChange(models.Model):
"""
Tracks changes to the status of a Game.
"""
game = models.ForeignKey(
Game, on_delete=models.CASCADE, related_name="status_changes"
)
old_status = models.CharField(
max_length=1, choices=Game.Status.choices, blank=True, null=True
)
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
timestamp = models.DateTimeField(null=True)
def __str__(self):
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
class Meta:
ordering = ["-timestamp"]
+83 -6
View File
@@ -1,12 +1,89 @@
from django.db.models.signals import m2m_changed import logging
from datetime import timedelta
from django.db.models import F, Sum
from django.db.models.signals import (
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save,
)
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from games.models import Purchase from games.models import Game, GameStatusChange, Purchase, Session
logger = logging.getLogger("games")
@receiver(m2m_changed, sender=Purchase.games.through) @receiver(m2m_changed, sender=Purchase.games.through)
def update_num_purchases(sender, instance, **kwargs): def update_num_purchases(sender, instance, action, reverse, **kwargs):
instance.num_purchases = instance.games.count() if not reverse and action.startswith("post_"):
instance.updated_at = now() instance.num_purchases = instance.games.count()
instance.save(update_fields=["num_purchases"]) instance.updated_at = now()
instance.save(update_fields=["num_purchases", "updated_at"])
@receiver(pre_delete, sender=Game)
def update_purchase_counts_on_game_delete(sender, instance, **kwargs):
"""
Update num_purchases on related Purchase objects when a Game is deleted.
m2m_changed is not fired when a related object is deleted.
"""
for purchase in instance.purchases.all():
if purchase.num_purchases > 0:
purchase.num_purchases -= 1
if purchase.num_purchases == 0:
purchase.delete()
else:
purchase.updated_at = now()
purchase.save(update_fields=["num_purchases", "updated_at"])
@receiver([post_save, post_delete], sender=Session)
def update_game_playtime(sender, instance, **kwargs):
# During cascade deletes the related Game may already have been removed.
# Use the FK id to look up the Game safely and bail out if it no longer exists.
game_pk = getattr(instance, "game_id", None)
if not game_pk:
return
game = Game.objects.filter(pk=game_pk).first()
if not game:
return
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
)["total_playtime"]
game.playtime = total_playtime if total_playtime else timedelta(0)
game.save(update_fields=["playtime"])
@receiver(pre_save, sender=Game)
def game_status_changed(sender, instance, **kwargs):
"""
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
"""
try:
old_instance = sender.objects.get(pk=instance.pk)
old_status = old_instance.status
logger.info("[game_status_changed]: Previous status exists.")
except sender.DoesNotExist:
# Handle the case where the instance was deleted before the signal was sent
logger.info("[game_status_changed]: Previous status does not exist.")
return
if old_status != instance.status:
logger.info(
"[game_status_changed]: Status changed from {} to {}".format(
old_status, instance.status
)
)
GameStatusChange.objects.create(
game=instance,
old_status=old_status,
new_status=instance.status,
timestamp=now(),
)
else:
logger.info("[game_status_changed]: Status has not changed")
+3151 -3311
View File
File diff suppressed because it is too large Load Diff
+3 -19
View File
@@ -21,27 +21,11 @@ function setupElementHandlers() {
"#id_name", "#id_name",
"#id_related_purchase", "#id_related_purchase",
]); ]);
disableElementsWhenValueNotEqual(
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers); document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers); document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => { getEl("#id_type").addEventListener("change", () => {
setupElementHandlers(); setupElementHandlers();
}; }
);
document.body.addEventListener("htmx:beforeRequest", function (event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_games") {
var idEditionValue = document.getElementById("id_games").value;
// Condition to check - replace this with your actual logic
if (idEditionValue != "") {
event.preventDefault(); // This cancels the HTMX request
}
}
});
+5
View File
@@ -43,6 +43,7 @@ function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const targetElement = document.querySelector(syncItem.target); const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) { if (targetElement && valueToSync !== null) {
console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
targetElement[syncItem.target_value] = valueToSync; targetElement[syncItem.target_value] = valueToSync;
} }
} }
@@ -184,13 +185,17 @@ function disableElementsWhenValueNotEqual(
function disableElementsWhenTrue(targetSelect, targetValue, elementList) { function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([ return conditionalElementHandler([
() => { () => {
console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
return getEl(targetSelect).value == targetValue; return getEl(targetSelect).value == targetValue;
}, },
elementList, elementList,
(el) => { (el) => {
console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
el.disabled = "disabled"; el.disabled = "disabled";
}, },
(el) => { (el) => {
console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
el.disabled = ""; el.disabled = "";
}, },
]); ]);
+15 -19
View File
@@ -1,10 +1,8 @@
import requests
from django.db.models import ExpressionWrapper, F, FloatField, Q
from django.template.defaultfilters import floatformat
from django.utils.timezone import now
from django_q.models import Task
import logging import logging
import requests
from django.template.defaultfilters import floatformat
logger = logging.getLogger("games") logger = logging.getLogger("games")
from games.models import ExchangeRate, Purchase from games.models import ExchangeRate, Purchase
@@ -40,7 +38,9 @@ def convert_prices():
exchange_rate = ExchangeRate.objects.filter( exchange_rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year currency_from=currency_from, currency_to=currency_to, year=year
).first() ).first()
logger.info(f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}") logger.info(
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
)
if not exchange_rate: if not exchange_rate:
logger.info( logger.info(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..." f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
@@ -78,17 +78,13 @@ def convert_prices():
def calculate_price_per_game(): def calculate_price_per_game():
"""
This task is deprecated because price_per_game is now a GeneratedField.
It is kept here to prevent errors from lingering scheduled tasks.
"""
try: try:
last_task = Task.objects.filter(group="Update price per game").first() from django_q.models import Schedule
last_run = last_task.started
except Task.DoesNotExist or AttributeError: Schedule.objects.filter(func="games.tasks.calculate_price_per_game").delete()
last_run = now() except Exception:
purchases = Purchase.objects.filter(converted_price__isnull=False).filter( pass
Q(updated_at__gte=last_run) | Q(price_per_game__isnull=True)
)
logger.info(f"[calculate_price_per_game]: Updating {purchases.count()} purchases.")
purchases.update(
price_per_game=ExpressionWrapper(
F("converted_price") / F("num_purchases"), output_field=FloatField()
)
)
+3 -3
View File
@@ -1,6 +1,6 @@
<c-vars color="blue" size="base" /> <c-vars color="blue" size="base" type="button" />
<button type="button" <button type="{{ type }}"
title="{{ title }}" title="{{ title }}"
class=" {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} "> class="{{ class }} {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
{{ slot }} {{ slot }}
</button> </button>
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="inline-flex rounded-md shadow-sm" role="group"> <div class="inline-flex rounded-md shadow-xs" role="group">
{% if slot %}{{ slot }}{% endif %} {% if slot %}{{ slot }}{% endif %}
{% for button in buttons %} {% for button in buttons %}
{% if button.slot %} {% if button.slot %}
@@ -4,19 +4,19 @@
{% if color == "gray" %} {% if color == "gray" %}
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }} {{ slot }}
</button> </button>
{% elif color == "red" %} {% elif color == "red" %}
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }} {{ slot }}
</button> </button>
{% elif color == "green" %} {% elif color == "green" %}
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white"> class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }} {{ slot }}
</button> </button>
{% endif %} {% endif %}
+1 -1
View File
@@ -4,7 +4,7 @@ text
{% endcomment %} {% endcomment %}
<a href="{{ link }}" <a href="{{ link }}"
title="{{ title }}" title="{{ title }}"
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"> class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline"> {% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg> </svg>
+1 -1
View File
@@ -5,7 +5,7 @@ text
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
autofocus autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"> class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
+3 -3
View File
@@ -1,5 +1,5 @@
<div class="relative ml-3"> <span class="{% if display == 'flex' %}flex{% else %}inline-flex{% endif %} gap-2 items-center align-middle {{class}}">
<span class="rounded-xl w-2 h-2 absolute -left-3 top-2 <span class="rounded-xl w-3 h-3
{% if status == "u" %} {% if status == "u" %}
bg-gray-500 bg-gray-500
{% elif status == "p" %} {% elif status == "p" %}
@@ -13,4 +13,4 @@
{% endif %} {% endif %}
">&nbsp;</span> ">&nbsp;</span>
{{ slot }} {{ slot }}
</div> </span>
+1 -1
View File
@@ -1,7 +1,7 @@
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"> <h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
{{ slot }} {{ slot }}
{% if badge %} {% if badge %}
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2"> <span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2">
{{ badge }} {{ badge }}
</span> </span>
{% endif %} {% endif %}
@@ -0,0 +1,3 @@
<svg class="dark:text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

+1 -1
View File
@@ -2,7 +2,7 @@
x="0px" x="0px"
y="0px" y="0px"
viewBox="0 0 48 48" viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4"> class="w-4 h-4">
<path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z"> <path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z">
</path> </path>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 861 B

After

Width:  |  Height:  |  Size: 834 B

+55 -38
View File
@@ -12,6 +12,10 @@
{% django_htmx_script %} {% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script> <script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC // On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -35,46 +39,59 @@
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div> </div>
{{ scripts }} {{ scripts }}
<script> <script type="module">
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); document.addEventListener('DOMContentLoaded', () => {
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); if (window.mountCrownIcon) {
window.mountCrownIcon('#crown-icon-mount-point', {
// Change the icons inside the button based on previous settings mastered: {{ game.mastered|yesno:"true,false" }}
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { });
themeToggleLightIcon.classList.remove('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
}
var themeToggleBtn = document.getElementById('theme-toggle');
themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
} }
// Theme toggle logic
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
// Ensure all elements are found before proceeding
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
// Initial state of icons based on current theme
// The FOUC script in <head> already set document.documentElement.classList.add/remove('dark')
// So we just need to set the icon visibility based on that.
if (document.documentElement.classList.contains('dark')) {
themeToggleLightIcon.classList.remove('hidden');
themeToggleDarkIcon.classList.add('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
themeToggleLightIcon.classList.add('hidden');
}
themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else { // current theme is dark, switch to light
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else { // no theme in local storage, use system preference
if (document.documentElement.classList.contains('dark')) { // currently dark, switch to light
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else { // currently light, switch to dark
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
}
}); });
</script> </script>
</body> </body>
+1 -1
View File
@@ -2,7 +2,7 @@
<div data-popover <div data-popover
id="{{ id }}" id="{{ id }}"
role="tooltip" role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800"> class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
<div class="px-3 py-2">{{ popover_content }}</div> <div class="px-3 py-2">{{ popover_content }}</div>
<div data-popper-arrow></div> <div data-popper-arrow></div>
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component --> <!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="pb-4 bg-white dark:bg-gray-900"> <div class="pb-4 bg-white dark:bg-gray-900">
<label for="table-search" class="sr-only">Search</label> <label for="table-search" class="sr-only">Search</label>
<div class="relative mt-1"> <div class="relative mt-1">
<div class="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none"> <div class="absolute inset-y-3 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg> </svg>
+2 -2
View File
@@ -7,12 +7,12 @@
{{ header_action }} {{ header_action }}
</c-table-header> </c-table-header>
{% endif %} {% endif %}
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden"> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">
<tr> <tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %} {% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr> </tr>
</thead> </thead>
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden"> <tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">
{% for row in rows %}<c-table-row :data=row />{% endfor %} {% for row in rows %}<c-table-row :data=row />{% endfor %}
</tbody> </tbody>
</table> </table>
+1 -1
View File
@@ -1,4 +1,4 @@
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2 [&_td:last-child]:text-right"> <tr class="odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2 [&_td:last-child]:text-right">
{% if slot %} {% if slot %}
{{ slot }} {{ slot }}
{% else %} {% else %}
@@ -0,0 +1,16 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<form method="post" class="dark:text-white">
{% csrf_token %}
<div>
<p>Are you sure you want to delete this status change?</p>
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
<a href="{% url 'view_game' object.game.id %}" class="">
<c-button color="gray" class="w-full">Cancel</c-button>
</a>
</div>
</form>
</div>
</c-layouts.base>
@@ -0,0 +1,7 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<c-simple-table :columns=["Test"] :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
</c-layouts.base>
+1 -1
View File
@@ -3,7 +3,7 @@
{{ title }} {{ title }}
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center"> <div class="text-slate-300 mx-auto max-w-(--breakpoint-lg) text-center">
{% if session_count > 0 %} {% if session_count > 0 %}
You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}. You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
{% elif not game_available or not platform_available %} {% elif not game_available or not platform_available %}
+6
View File
@@ -0,0 +1,6 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
</c-layouts.base>
+1 -1
View File
@@ -1,6 +1,6 @@
<c-layouts.base> <c-layouts.base>
{% load static %} {% load static %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center"> <div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action /> <c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div> </div>
</c-layouts.base> </c-layouts.base>
+1 -1
View File
@@ -35,7 +35,7 @@
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group"> <td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
<span class="inline-block relative"> <span class="inline-block relative">
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100" <a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-xs group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
href="{% url 'view_game' session.game.id %}"> href="{% url 'view_game' session.game.id %}">
{{ session.game.name }} {{ session.game.name }}
</a> </a>
+26 -12
View File
@@ -1,6 +1,6 @@
{% load static %} {% load static %}
<nav class="bg-white border-gray-200 dark:bg-gray-900 dark:border-gray-700"> <nav class="bg-white border-gray-200 dark:bg-gray-900 dark:border-gray-700">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> <div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{% url 'index' %}" <a href="{% url 'index' %}"
class="flex items-center space-x-3 rtl:space-x-reverse"> class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{% static 'icons/schedule.png' %}" <img src="{% static 'icons/schedule.png' %}"
@@ -12,7 +12,7 @@
</a> </a>
<button data-collapse-toggle="navbar-dropdown" <button data-collapse-toggle="navbar-dropdown"
type="button" type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-dropdown" aria-controls="navbar-dropdown"
aria-expanded="false"> aria-expanded="false">
<span class="sr-only">Open main menu</span> <span class="sr-only">Open main menu</span>
@@ -26,19 +26,29 @@
</button> </button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown"> <div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"> <ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li class="text-white flex flex-col items-center text-xs"> <li class="flex items-center">
<span class="flex uppercase gap-1">Today<span class="text-gray-400">·</span>Last 7 days</span> <button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
<span class="flex items-center gap-1">{{ today_played }}<span class="text-gray-400">·</span>{{ last_7_played }}</span> <svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</li>
<li class="dark:text-white flex flex-col items-center text-xs">
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{{ today_played }}<span class="dark:text-gray-400">·</span>{{ last_7_played }}</span>
</li> </li>
<li> <li>
<a href="#" <a href="#"
class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
aria-current="page">Home</a> aria-current="page">Home</a>
</li> </li>
<li> <li>
<button id="dropdownNavbarNewLink" <button id="dropdownNavbarNewLink"
data-dropdown-toggle="dropdownNavbarNew" data-dropdown-toggle="dropdownNavbarNew"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent"> class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
New New
<svg class="w-2.5 h-2.5 ms-2.5" <svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true" aria-hidden="true"
@@ -50,7 +60,7 @@
</button> </button>
<!-- Dropdown menu --> <!-- Dropdown menu -->
<div id="dropdownNavbarNew" <div id="dropdownNavbarNew"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600"> class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" <ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton"> aria-labelledby="dropdownLargeButton">
<li> <li>
@@ -79,7 +89,7 @@
<li> <li>
<button id="dropdownNavbarManageLink" <button id="dropdownNavbarManageLink"
data-dropdown-toggle="dropdownNavbarManage" data-dropdown-toggle="dropdownNavbarManage"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent"> class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
Manage Manage
<svg class="w-2.5 h-2.5 ms-2.5" <svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true" aria-hidden="true"
@@ -91,7 +101,7 @@
</button> </button>
<!-- Dropdown menu --> <!-- Dropdown menu -->
<div id="dropdownNavbarManage" <div id="dropdownNavbarManage"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600"> class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" <ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton"> aria-labelledby="dropdownLargeButton">
<li> <li>
@@ -106,6 +116,10 @@
<a href="{% url 'list_platforms' %}" <a href="{% url 'list_platforms' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
</li> </li>
<li>
<a href="{% url 'list_playevents' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
</li>
<li> <li>
<a href="{% url 'list_purchases' %}" <a href="{% url 'list_purchases' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
@@ -119,11 +133,11 @@
</li> </li>
<li> <li>
<a href="{% url 'stats_by_year' global_current_year %}" <a href="{% url 'stats_by_year' global_current_year %}"
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a> class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li> </li>
<li> <li>
<a href="{% url 'logout' %}" <a href="{% url 'logout' %}"
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log
out</a> out</a>
</li> </li>
</ul> </ul>
@@ -0,0 +1,45 @@
<div class="flex gap-2 items-center"
x-data="{
status: '{{ game.status }}',
status_display: '{{ game.get_status_display }}',
open: false,
saving: false,
setStatus(newStatus, newStatusDisplay) {
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
fetch(`/api/games/{{ game.id }}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ status: newStatus })
}).then(() => {
document.body.dispatchEvent(new CustomEvent('status-changed'));
})
.finally(() => this.saving = false);
}
}"
>
<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">
<button type="button" @click="open = !open" class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
<span class="flex flex-row gap-4 justify-between items-center">
{% for status_value, status_label in game_statuses %}
<template x-if="status == '{{ status_value }}'">
<c-gamestatus display="flex" status="{{ status_value }}">{{ status_label }}</c-gamestatus>
</template>
{% endfor %}
<c-icon.arrowdown />
</span>
<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">
<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">
{% for status_value, status_label in game_statuses %}
<li><a href="#" @click.prevent.stop="setStatus('{{ status_value }}', '{{ status_label }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': status === '{{ status_value }}' }"><c-gamestatus display="flex" status="{{ status_value }}" class="text-slate-300">{{ status_label }}</c-gamestatus></a></li>
{% endfor %}
</ul>
</div>
</button>
</div>
<div x-show="saving" style="display: none;">Saving...</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
<ul class="list-disc list-inside">
{% for change in statuschanges %}
<li class="text-slate-500">
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status">{{ change.get_new_status_display }}</c-gamestatus> (<a href="{% url 'edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'delete_statuschange' change.id %}">Delete</a>)</li>
{% endfor %}
</ul>
+12 -7
View File
@@ -1,12 +1,17 @@
<c-layouts.base> <c-layouts.base>
{% load static %} {% load static %}
{% load duration_formatter %}
{% partialdef purchase-name %} {% partialdef purchase-name %}
{% if purchase.type != 'game' %} {% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.first_game.id> <c-gamelink :game_id=purchase.first_game.id>
{{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }}) {% if purchase.game_name %}{{ purchase.game_name }}{% else %}{{ purchase.name }}{% endif %} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
</c-gamelink> </c-gamelink>
{% else %} {% else %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name /> {% if purchase.game_name %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name />
{% else %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
{% endif %}
{% endif %} {% endif %}
{% endpartialdef %} {% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
@@ -107,7 +112,7 @@
{% for month in month_playtimes %} {% for month in month_playtimes %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime | format_duration }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -153,7 +158,7 @@
<thead> <thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -162,7 +167,7 @@
<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">
<c-gamelink :game_id=game.id :name=game.name /> <c-gamelink :game_id=game.id :name=game.name />
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.total_playtime | format_duration }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -172,14 +177,14 @@
<thead> <thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in total_playtime_per_platform %} {% for item in total_playtime_per_platform %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.playtime | format_duration }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+71 -12
View File
@@ -2,7 +2,7 @@
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div id="game-info" class="mb-10"> <div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3"> <div class="flex gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl"> <span class="text-balance max-w-120 text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %} <span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
</span> </span>
</div> </div>
@@ -16,7 +16,7 @@
class="size-6"> class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg> </svg>
{{ hours_sum }} {{ game.playtime_formatted }}
</c-popover> </c-popover>
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center"> <c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
@@ -52,29 +52,75 @@
{{ playrange }} {{ playrange }}
</c-popover> </c-popover>
</div> </div>
<div class="mb-6 text-slate-400"> <div class="flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<span class="uppercase font-bold text-slate-300">Status</span> <span class="uppercase">Original year</span>
<c-gamestatus :status="game.status"> <span class="text-black dark:text-slate-300">{{ game.original_year_released }}</span>
{{ game.get_status_display }} </div>
</c-gamestatus> <div class="flex gap-2 items-center"
>
<span class="uppercase">Status</span>
{% include "partials/gamestatus_selector.html" %}
{% if game.mastered %}👑{% endif %} {% if game.mastered %}👑{% endif %}
</div> </div>
<div class="flex gap-2 items-center"
x-data="{ open: false }"
>
<span class="uppercase">Played</span>
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
<a href="{% url 'add_playevent' %}">
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
<span x-text="played"></span> times
</button>
</a>
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
<c-icon.arrowdown />
<div
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="{% url 'add_playevent_for_game' game.id %}">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
fetch('{% url 'api-1.0.0:create_playevent' %}', { method: 'POST', headers: { 'X-CSRFToken': '{{ csrf_token }}' }, body: '{"game_id": {{ game.id }}}'})
}
</script>
</ul>
</div>
</button>
</div>
</div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<span class="uppercase font-bold text-slate-300">Platform</span> <span class="uppercase">Platform</span>
<span>{{ game.platform }}</span> <span class="text-black dark:text-slate-300">{{ game.platform }}</span>
</div> </div>
</div> </div>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group"> <div class="inline-flex rounded-md shadow-xs mb-3" role="group">
<a href="{% url 'edit_game' game.id %}"> <a href="{% url 'edit_game' game.id %}">
<button type="button" <button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
Edit Edit
</button> </button>
</a> </a>
<a href="{% url 'delete_game' game.id %}"> <a href="{% url 'delete_game' game.id %}">
<button type="button" <button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
Delete Delete
</button> </button>
</a> </a>
@@ -96,6 +142,19 @@
No sessions yet. No sessions yet.
{% endif %} {% endif %}
</div> </div>
<!-- list all playevents -->
<div class="mb-6">
<c-h1 :badge="playevent_count">Play Events</c-h1>
{% if playevent_count %}
<c-simple-table :rows=playevent_data.rows :columns=playevent_data.columns />
{% else %}
No play events yet.
{% endif %}
</div>
<div class="mb-6" id="history-container" hx-get="" hx-trigger="status-changed from:body" hx-select="#history-container" hx-swap="outerHTML">
<c-h1 :badge="statuschange_count">History</c-h1>
{% include "partials/history.html" %}
</div>
</div> </div>
<script> <script>
function getSessionCount() { function getSessionCount() {
+2 -2
View File
@@ -9,12 +9,12 @@
{{ purchase.name }} {{ purchase.name }}
{% endif %} {% endif %}
</div> </div>
<span class="text-balance max-w-[30rem] text-4xl"> <span class="text-balance max-w-120 text-4xl">
<span class="font-bold font-serif"> <span class="font-bold font-serif">
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}}) {{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
</span> </span>
</span> </span>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group"> <div class="inline-flex rounded-md shadow-xs mb-3" role="group">
<a href="{% url 'edit_purchase' purchase.id %}"> <a href="{% url 'edit_purchase' purchase.id %}">
<button type="button" <button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
+12
View File
@@ -0,0 +1,12 @@
from datetime import timedelta
from django import template
from common.time import durationformat, format_duration
register = template.Library()
@register.filter(name="format_duration")
def filter_format_duration(duration: timedelta, argument: str = durationformat):
return format_duration(duration, format_string=argument)
+48 -1
View File
@@ -1,6 +1,16 @@
from django.urls import path from django.urls import path
from games.views import device, game, general, platform, purchase, session from games.api import api
from games.views import (
device,
game,
general,
platform,
playevent,
purchase,
session,
statuschange,
)
urlpatterns = [ urlpatterns = [
path("", general.index, name="index"), path("", general.index, name="index"),
@@ -25,6 +35,23 @@ urlpatterns = [
name="delete_platform", name="delete_platform",
), ),
path("platform/list", platform.list_platforms, name="list_platforms"), path("platform/list", platform.list_platforms, name="list_platforms"),
path("playevent/list", playevent.list_playevents, name="list_playevents"),
path("playevent/add", playevent.add_playevent, name="add_playevent"),
path(
"playevent/add/for-game/<int:game_id>",
playevent.add_playevent,
name="add_playevent_for_game",
),
path(
"playevent/edit/<int:playevent_id>",
playevent.edit_playevent,
name="edit_playevent",
),
path(
"playevent/delete/<int:playevent_id>",
playevent.delete_playevent,
name="delete_playevent",
),
path("purchase/add", purchase.add_purchase, name="add_purchase"), path("purchase/add", purchase.add_purchase, name="add_purchase"),
path( path(
"purchase/add/for-game/<int:game_id>", "purchase/add/for-game/<int:game_id>",
@@ -109,6 +136,26 @@ urlpatterns = [
), ),
path("session/list", session.list_sessions, name="list_sessions"), path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"), path("session/search", session.search_sessions, name="search_sessions"),
path(
"statuschange/add",
statuschange.AddStatusChangeView.as_view(),
name="add_statuschange",
),
path(
"statuschange/edit/<int:statuschange_id>",
statuschange.EditStatusChangeView.as_view(),
name="edit_statuschange",
),
path(
"statuschange/delete/<int:pk>",
statuschange.GameStatusChangeDeleteView.as_view(),
name="delete_statuschange",
),
path(
"statuschange/list",
statuschange.GameStatusChangeListView.as_view(),
name="list_statuschanges",
),
path("stats/", general.stats_alltime, name="stats_alltime"), path("stats/", general.stats_alltime, name="stats_alltime"),
path( path(
"stats/<int:year>", "stats/<int:year>",
+38 -9
View File
@@ -22,8 +22,6 @@ from common.components import (
) )
from common.time import ( from common.time import (
dateformat, dateformat,
durationformat,
durationformat_manual,
format_duration, format_duration,
local_strftime, local_strftime,
timeformat, timeformat,
@@ -32,6 +30,7 @@ from common.utils import build_dynamic_filter, safe_division, truncate
from games.forms import GameForm from games.forms import GameForm
from games.models import Game, Purchase from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
from games.views.playevent import create_playevent_tabledata
@login_required @login_required
@@ -113,8 +112,12 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
), ),
game.year_released, game.year_released,
render_to_string( render_to_string(
"cotton/gamestatus.html", "partials/gamestatus_selector.html",
{"status": game.status, "slot": game.get_status_display()}, {
"game": game,
"game_statuses": Game.Status.choices,
},
request=request,
), ),
game.wikidata, game.wikidata,
local_strftime(game.created_at, dateformat), local_strftime(game.created_at, dateformat),
@@ -309,11 +312,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
session_id=session.pk, session_id=session.pk,
), ),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
( session.duration_formatted_with_mark,
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
else f"{format_duration(session.duration_manual, durationformat_manual)}*"
),
render_to_string( render_to_string(
"cotton/button_group.html", "cotton/button_group.html",
{ {
@@ -351,8 +350,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
], ],
} }
playevents = game.playevents.all()
playevent_count = playevents.count()
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
statuschange_data = {
"columns": [
"Old Status",
"New Status",
"Timestamp",
],
"rows": [
[
statuschange.get_old_status_display()
if statuschange.old_status
else "-",
statuschange.get_new_status_display(),
local_strftime(statuschange.timestamp, dateformat),
]
for statuschange in statuschanges
],
}
context: dict[str, Any] = { context: dict[str, Any] = {
"statuschange_data": statuschange_data,
"statuschange_count": statuschange_count,
"statuschanges": statuschanges,
"game": game, "game": game,
"game_statuses": Game.Status.choices,
"playrange": playrange, "playrange": playrange,
"purchase_count": game.purchases.count(), "purchase_count": game.purchases.count(),
"session_average_without_manual": round( "session_average_without_manual": round(
@@ -366,6 +393,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"title": f"Game Overview - {game.name}", "title": f"Game Overview - {game.name}",
"hours_sum": total_hours, "hours_sum": total_hours,
"purchase_data": purchase_data, "purchase_data": purchase_data,
"playevent_data": playevent_data,
"playevent_count": playevent_count,
"session_data": session_data, "session_data": session_data,
"session_page_obj": session_page_obj, "session_page_obj": session_page_obj,
"session_elided_page_range": ( "session_elided_page_range": (
+61 -56
View File
@@ -22,10 +22,10 @@ def model_counts(request: HttpRequest) -> dict[str, bool]:
timestamp_start__day=this_day, timestamp_start__day=this_day,
timestamp_start__month=this_month, timestamp_start__month=this_month,
timestamp_start__year=this_year, timestamp_start__year=this_year,
).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"] ).aggregate(time=Sum(F("duration_total")))["time"]
last_7_played = Session.objects.filter( last_7_played = Session.objects.filter(
timestamp_start__gte=(now - timedelta(days=7)) timestamp_start__gte=(now - timedelta(days=7))
).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"] ).aggregate(time=Sum(F("duration_total")))["time"]
return { return {
"game_available": Game.objects.exists(), "game_available": Game.objects.exists(),
@@ -137,19 +137,13 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
) )
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = Game.objects.filter(
Game.objects.filter(sessions__in=this_year_sessions) sessions__in=this_year_sessions
.annotate( ).distinct()
total_playtime=Sum(
F("sessions__duration_calculated") + F("sessions__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
month_playtimes = ( month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start")) this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month") .values("month")
.annotate(playtime=Sum("duration_calculated")) .annotate(playtime=Sum("duration_total"))
.order_by("month") .order_by("month")
) )
for month in month_playtimes: for month in month_playtimes:
@@ -162,18 +156,14 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
.first() .first()
) )
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = ( total_playtime_per_platform = (
this_year_sessions.values("game__platform__name") this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) .annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name")) .annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime") .values("platform_name", "playtime")
.order_by("-total_playtime") .order_by("-playtime")
) )
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = ( backlog_decrease_count = (
Purchase.objects.all().intersection(purchases_finished_this_year).count() Purchase.objects.all().intersection(purchases_finished_this_year).count()
@@ -305,27 +295,39 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
sessions__in=this_year_sessions sessions__in=this_year_sessions
).distinct() ).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year) this_year_purchases = Purchase.objects.filter(
this_year_purchases_with_currency = this_year_purchases.prefetch_related("games") date_purchased__year=year
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( ).prefetch_related("games")
date_refunded=None # purchased this year
).exclude(ownership_type=Purchase.DEMO) # not refunded
this_year_purchases_refunded = this_year_purchases_with_currency.refunded() this_year_purchases_without_refunded = Purchase.objects.filter(
date_refunded=None, date_purchased__year=year
)
# purchased this year
# not refunded
# not finished
# not infinite
# only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(date_finished__isnull=True) this_year_purchases_without_refunded.exclude(
games__in=Game.objects.filter(status="f")
)
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc. )
# not finished
this_year_purchases_unfinished = ( this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.exclude(
date_dropped__isnull=True games__status__in="ura"
) )
) )
# abandoned
# retired
this_year_purchases_dropped = ( this_year_purchases_dropped = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.exclude(
date_dropped__isnull=False games__in=Game.objects.filter(status="ar")
) )
) )
@@ -341,15 +343,21 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
* 100 * 100
) )
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) purchases_finished_this_year = Purchase.objects.filter(
games__playevents__ended__year=year
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
purchases_finished_this_year_released_this_year = ( purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(games__year_released=year).order_by( purchases_finished_this_year.filter(games__year_released=year).order_by(
"date_finished" "games__playevents__ended"
) )
) )
purchased_this_year_finished_this_year = ( purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(date_finished__year=year) this_year_purchases_without_refunded.filter(
).order_by("date_finished") games__playevents__ended__year=year
).annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
).order_by("games__playevents__ended")
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price")) total_spent=Sum(F("converted_price"))
@@ -357,22 +365,21 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(sessions__in=this_year_sessions) Game.objects.filter(sessions__timestamp_start__year=year)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("sessions__duration_calculated") + F("sessions__duration_manual") F("sessions__duration_calculated"),
) )
) )
.values("id", "name", "total_playtime") .filter(total_playtime__gt=timedelta(0))
) )
month_playtimes = ( month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start")) this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month") .values("month")
.annotate(playtime=Sum("duration_calculated")) .annotate(playtime=Sum("duration_total"))
.order_by("month") .order_by("month")
) )
for month in month_playtimes:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(sessions__in=this_year_sessions) Game.objects.filter(sessions__in=this_year_sessions)
@@ -381,22 +388,19 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
.first() .first()
) )
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime") top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = ( total_playtime_per_platform = (
this_year_sessions.values("game__platform__name") this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) .annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name")) .annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime") .values("platform_name", "playtime")
.order_by("-total_playtime") .order_by("-playtime")
) )
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = ( backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year) Purchase.objects.filter(date_purchased__year__lt=year)
.intersection(purchases_finished_this_year) .filter(games__status="f")
.filter(games__playevents__ended__year=year)
.count() .count()
) )
@@ -412,7 +416,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = last_session.game last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases.count()
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
date_purchased__year=year
)
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count() all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count() this_year_purchases_dropped_count = this_year_purchases_dropped.count()
@@ -439,15 +446,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"all_finished_this_year": purchases_finished_this_year.prefetch_related( "all_finished_this_year": purchases_finished_this_year.prefetch_related(
"games" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"all_finished_this_year_count": purchases_finished_this_year.count(), "all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related( "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"games" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"games" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"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),
@@ -465,9 +472,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"all_purchased_refunded_this_year": this_year_purchases_refunded, "all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year": this_year_purchases_with_currency.order_by( "all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
"date_purchased"
),
"all_purchased_this_year_count": all_purchased_this_year_count, "all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count, "backlog_decrease_count": backlog_decrease_count,
"longest_session_time": ( "longest_session_time": (
+220
View File
@@ -0,0 +1,220 @@
import logging
from datetime import datetime, timedelta
from typing import Any, Callable, TypedDict
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import QuerySet
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.components import A, Button, Icon
from common.time import dateformat, format_duration, local_strftime
from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session
logger = logging.getLogger("games")
class TableData(TypedDict):
header_action: Callable[..., Any]
columns: list[str]
rows: list[list[Any]]
def create_playevent_tabledata(
playevents: list[PlayEvent] | BaseManager[PlayEvent] | QuerySet[PlayEvent],
exclude_columns: list[str] = [],
request: HttpRequest | None = None,
) -> TableData:
column_list = [
"Game",
"Started",
"Ended",
"Days to finish",
"Note",
"Created",
"Actions",
]
filtered_column_list = filter(
lambda x: x not in exclude_columns,
column_list,
)
excluded_column_indexes = [column_list.index(column) for column in exclude_columns]
row_list = [
[
playevent.game,
playevent.started.strftime(dateformat) if playevent.started else "-",
playevent.ended.strftime(dateformat) if playevent.ended else "-",
playevent.days_to_finish if playevent.days_to_finish else "-",
playevent.note,
local_strftime(playevent.created_at, dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("edit_playevent", args=[playevent.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("delete_playevent", args=[playevent.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for playevent in playevents
]
filtered_row_list = [
[column for idx, column in enumerate(row) if idx not in excluded_column_indexes]
for row in row_list
]
return {
"header_action": A([], Button([], "Add play event"), url="add_playevent"),
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
def _get_formatted_playtime_for_game_sessions_in_range(
game: Game,
start_timestamp: datetime | None = None,
end_timestamp: datetime | None = None,
) -> str:
"""
Calculates and formats the total playtime for a game's sessions
between specified start and end timestamps. If timestamps are not provided,
it uses the earliest and latest session start times for the game.
Returns "0h 00m" if no sessions exist for the game or if the range is invalid.
"""
sessions_queryset = game.sessions.all()
if not sessions_queryset.exists():
return "0h 00m"
actual_start_ts = (
start_timestamp
if start_timestamp is not None
else sessions_queryset.earliest("timestamp_start").timestamp_start
)
actual_end_ts = (
end_timestamp
if end_timestamp is not None
else sessions_queryset.latest("timestamp_start").timestamp_start
)
sessions_in_range = sessions_queryset.filter(
timestamp_start__gte=actual_start_ts, timestamp_start__lte=actual_end_ts
)
return format_duration(sessions_in_range.total_duration_unformatted(), "%Hh %mm")
@login_required
def list_playevents(request: HttpRequest) -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
playevents = PlayEvent.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(playevents, limit)
page_obj = paginator.get_page(page_number)
playevents = page_obj.object_list
context: dict[str, Any] = {
"title": "Manage play events",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": create_playevent_tabledata(playevents, request=request),
}
return render(request, "list_playevents.html", context)
@login_required
def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
initial: dict[str, Any] = {}
if game_id:
# coming from add_playevent_for_game url path
game = get_object_or_404(Game, id=game_id)
initial["game"] = game
try:
# First, try to get the latest session. If no sessions, then no playtime.
latest_session = game.sessions.latest("timestamp_start")
latest_session_ts = latest_session.timestamp_start
# Now, determine the start date for the new playevent.
# This will be either the day after the last playevent ended, or the earliest session.
try:
latest_playevent = game.playevents.latest("ended")
# Start date for the new PlayEvent form
new_playevent_form_start_date = latest_playevent.ended + timedelta(
days=1
)
initial["started"] = new_playevent_form_start_date
# Start timestamp for playtime calculation
playtime_calc_start_ts = datetime.combine(
new_playevent_form_start_date, datetime.min.time()
)
except PlayEvent.DoesNotExist:
# No previous playevents, so the new playevent starts from the earliest session.
earliest_session_ts = game.sessions.earliest(
"timestamp_start"
).timestamp_start
initial["started"] = earliest_session_ts.date()
playtime_calc_start_ts = earliest_session_ts
# The end date for the new PlayEvent form and playtime calculation is the latest session's start date.
initial["ended"] = latest_session_ts.date()
playtime_calc_end_ts = latest_session_ts
initial["note"] = _get_formatted_playtime_for_game_sessions_in_range(
game, playtime_calc_start_ts, playtime_calc_end_ts
)
except Session.DoesNotExist:
initial["started"] = None
initial["ended"] = None
initial["note"] = "0h 00m"
form = PlayEventForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
if not game_id:
# coming from add_playevent url path
game_id = form.instance.game.id
return HttpResponseRedirect(reverse("view_game", args=[game_id]))
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
context: dict[str, Any] = {}
playevent = get_object_or_404(PlayEvent, id=playevent_id)
form = PlayEventForm(request.POST or None, instance=playevent)
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse("view_game", args=[playevent.game.id]))
context = {
"form": form,
"title": "Edit Play Event",
}
return render(request, "add.html", context)
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
+10 -35
View File
@@ -51,8 +51,6 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
"Infinite", "Infinite",
"Purchased", "Purchased",
"Refunded", "Refunded",
"Finished",
"Dropped",
"Created", "Created",
"Actions", "Actions",
], ],
@@ -68,39 +66,11 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
if purchase.date_refunded if purchase.date_refunded
else "-" else "-"
), ),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat), purchase.created_at.strftime(dateformat),
render_to_string( render_to_string(
"cotton/button_group.html", "cotton/button_group.html",
{ {
"buttons": [ "buttons": [
{
"href": reverse(
"finish_purchase", args=[purchase.pk]
),
"slot": Icon("checkmark"),
"title": "Mark as finished",
}
if not purchase.date_finished
else {},
{
"href": reverse(
"drop_purchase", args=[purchase.pk]
),
"slot": Icon("eject"),
"title": "Mark as dropped",
}
if not purchase.date_dropped
else {},
{ {
"href": reverse( "href": reverse(
"refund_purchase", args=[purchase.pk] "refund_purchase", args=[purchase.pk]
@@ -170,7 +140,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
# context["script_name"] = "add_purchase.js" context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@@ -186,7 +156,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
context["purchase_id"] = str(purchase_id) context["purchase_id"] = str(purchase_id)
# context["script_name"] = "add_purchase.js" context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@@ -238,7 +208,12 @@ def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
if isinstance(games, int) or isinstance(games, str): if isinstance(games, int) or isinstance(games, str):
games = [games] games = [games]
form = PurchaseForm() form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter( qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
games__in=games, type=Purchase.GAME "games__sort_name"
).order_by("games__sort_name") )
form.fields["related_purchase"].queryset = qs
first_option = qs.first()
if first_option:
form.fields["related_purchase"].initial = first_option.id
return render(request, "partials/related_purchase_field.html", {"form": form}) return render(request, "partials/related_purchase_field.html", {"form": form})
+1 -10
View File
@@ -20,16 +20,12 @@ from common.components import (
) )
from common.time import ( from common.time import (
dateformat, dateformat,
durationformat,
durationformat_manual,
format_duration,
local_strftime, local_strftime,
timeformat, timeformat,
) )
from common.utils import truncate from common.utils import truncate
from games.forms import SessionForm from games.forms import SessionForm
from games.models import Game, Session from games.models import Game, Session
from games.views.general import use_custom_redirect
@login_required @login_required
@@ -130,11 +126,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
[ [
NameWithIcon(session_id=session.pk), NameWithIcon(session_id=session.pk),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
( session.duration_formatted_with_mark,
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
else f"{format_duration(session.duration_manual, durationformat_manual)}*"
),
session.device, session.device,
session.created_at.strftime(dateformat), session.created_at.strftime(dateformat),
render_to_string( render_to_string(
@@ -222,7 +214,6 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
@login_required @login_required
@use_custom_redirect
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse: def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
context = {} context = {}
session = get_object_or_404(Session, id=session_id) session = get_object_or_404(Session, id=session_id)
+57
View File
@@ -0,0 +1,57 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from games.forms import GameStatusChangeForm
from games.models import GameStatusChange
class EditStatusChangeView(LoginRequiredMixin, UpdateView):
model = GameStatusChange
form_class = GameStatusChangeForm
template_name = "add.html"
context_object_name = "form"
def get_object(self, queryset=None):
return get_object_or_404(GameStatusChange, id=self.kwargs["statuschange_id"])
def get_success_url(self):
return reverse_lazy("list_platforms")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = "Edit Platform"
return context
class AddStatusChangeView(LoginRequiredMixin, CreateView):
model = GameStatusChange
form_class = GameStatusChangeForm
template_name = "add.html"
def get_success_url(self):
return reverse_lazy("view_game", kwargs={"pk": self.object.game.id})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = "Add status change"
return context
class GameStatusChangeListView(LoginRequiredMixin, ListView):
model = GameStatusChange
template_name = "list_purchases.html"
context_object_name = "status_changes"
paginate_by = 10
def get_queryset(self):
return GameStatusChange.objects.select_related("game").all()
class GameStatusChangeDeleteView(LoginRequiredMixin, DeleteView):
model = GameStatusChange
template_name = "gamestatuschange_confirm_delete.html"
def get_success_url(self):
return reverse_lazy("view_game", kwargs={"game_id": self.object.game.id})
+2 -1
View File
@@ -4,9 +4,10 @@
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"npm-check-updates": "^16.14.20", "npm-check-updates": "^16.14.20",
"tailwindcss": "^3.4.14" "tailwindcss": "^4.1.18"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/cli": "^4.1.18",
"flowbite": "^2.4.1" "flowbite": "^2.4.1"
} }
} }
Generated
-1218
View File
File diff suppressed because it is too large Load Diff
+50 -36
View File
@@ -1,45 +1,59 @@
[tool.poetry] [project]
name = "timetracker" name = "timetracker"
version = "1.5.2" version = "1.6.1"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = [{ name = "Lukáš Kucharczyk", email = "lukas@kucharczyk.xyz" }]
license = "GPL" requires-python = ">=3.13,<4"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] license = "AGPL-3.0-or-later"
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"django>6.0",
"gunicorn>=23.0.0,<24",
"uvicorn>=0.30.1,<0.31",
"graphene-django>=3.2.0,<4",
"django-htmx>=1.18.0,<2",
"django-template-partials>=24.2,<25",
"markdown>=3.6,<4",
"django-cotton==2.3",
"django-q2>=1.7.4,<2",
"croniter>=5.0.1,<6",
"requests>=2.32.3,<3",
"pyyaml>=6.0.2,<7",
"django-ninja>1.5",
]
[tool.poetry.group.dev.dependencies] [project.scripts]
mypy = "^1.10.1" timetracker-import = "common.import_data:import_from_file"
pyyaml = "^6.0.1"
pytest = "^8.2.2"
django-extensions = "^3.2.3"
djhtml = "^3.0.6"
djlint = "^1.34.1"
isort = "^5.13.2"
pre-commit = "^3.7.1"
django-debug-toolbar = "^4.4.2"
[dependency-groups]
dev = [
"mypy>=1.10.1,<2",
"pyyaml>=6.0.1,<7",
"pytest>=8.2.2,<9",
"django-extensions>=3.2.3,<4",
"djhtml>=3.0.6,<4",
"djlint>=1.34.1,<2",
"isort>=5.13.2,<6",
"pre-commit>=3.7.1,<4",
"django-debug-toolbar>=4.4.2,<5",
"ruff"
]
[tool.poetry.dependencies] [tool.uv]
python = "^3.11"
django = "^5.0.6"
gunicorn = "^23.0.0"
uvicorn = "^0.30.1"
graphene-django = "^3.2.0"
django-htmx = "^1.18.0"
django-template-partials = "^24.2"
markdown = "^3.6"
django-cotton = "^1.2.1"
django-q2 = "^1.7.4" [tool.uv.build-backend]
croniter = "^5.0.1" module-name = ["timetracker"]
requests = "^2.32.3" module-root = ""
pyyaml = "^6.0.2"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["uv_build>=0.9.26,<0.10.0"]
build-backend = "poetry.core.masonry.api" build-backend = "uv_build"
[tool.isort]
[tool.poetry.scripts] profile = "black"
timetracker-import = "common.import_data:import_from_file"
+3 -3
View File
@@ -6,13 +6,13 @@ pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
nodejs nodejs
python3 python3
poetry uv
ruff ruff
]; ];
shellHook = '' shellHook = ''
python -m venv .venv uv venv --clear
. .venv/bin/activate . .venv/bin/activate
poetry install uv sync
''; '';
} }
-26
View File
@@ -1,26 +0,0 @@
const defaultTheme = require('tailwindcss/defaultTheme')
const colors = require('tailwindcss/colors');
module.exports = {
darkMode: 'class',
content: ["./games/**/*.{html,js}", './node_modules/flowbite/**/*.js'],
theme: {
extend: {
fontFamily: {
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
'condensed': ['IBM Plex Sans Condensed', ...defaultTheme.fontFamily.sans],
},
colors: {
'accent': colors.violet[600],
'background': colors.gray[800],
}
},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms'),
require('flowbite/plugin')
],
}
+39
View File
@@ -0,0 +1,39 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
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, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class SignalsTest(TestCase):
def test_deleting_game_with_sessions_does_not_raise(self):
# Create a game and attach a session to it
g = Game(name="Signal Test Game")
g.save()
s = Session(
game=g,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
# Sanity checks before delete
self.assertTrue(Game.objects.filter(pk=g.pk).exists())
self.assertEqual(g.sessions.count(), 1)
# Deleting the game should not raise (signals run during cascade)
g.delete()
# After deletion, the Game should be gone and no sessions remain
self.assertFalse(Game.objects.filter(pk=g.pk).exists())
self.assertEqual(Session.objects.filter(pk=s.pk).count(), 0)
+2 -1
View File
@@ -173,8 +173,9 @@ LOGGING = {
"loggers": { "loggers": {
"django": { "django": {
"handlers": ["console"], "handlers": ["console"],
"level": "INFO", "level": "WARNING",
}, },
"games": {"handlers": ["console"], "level": "INFO", "propagate": False},
}, },
} }
+3
View File
@@ -22,8 +22,11 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.generic import RedirectView from django.views.generic import RedirectView
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
from games.api import api
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker")), path("", RedirectView.as_view(url="/tracker")),
path("api/", api.urls),
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))), path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
path("login/", auth_views.LoginView.as_view(), name="login"), path("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"), path("logout/", auth_views.LogoutView.as_view(), name="logout"),
Generated
+1023
View File
File diff suppressed because it is too large Load Diff