Compare commits
54 Commits
843eed64d6
..
v1.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
277ecd1b55
|
|||
|
4e3a5ef682
|
|||
|
233f63f18e
|
|||
|
016f307240
|
|||
|
715acd6244
|
|||
|
0bc48d01a7
|
|||
|
c5646d0451
|
|||
|
710a0fc5bc
|
|||
|
1d0d16b4d4
|
|||
|
6b89bab0a6
|
|||
|
2bc2d98f88
|
|||
|
06096d471e
|
|||
|
40869e25f3
|
|||
|
4f0ac21ba3
|
|||
|
3801949fdb
|
|||
|
f895dc1265
|
|||
|
04601ca13d
|
|||
|
d53575ab48
|
|||
|
4e1f55855d
|
|||
|
95af4ceed6
|
|||
|
6bb89438df
|
|||
|
bd5525e57e
|
|||
|
5cac19be7b
|
|||
|
a6577a9e53
|
|||
|
243830a84a
|
|||
|
7032b8c7c7
|
|||
|
5cc1652002
|
|||
|
7cf2180192
|
|||
|
ad0641f95b
|
|||
|
abdcfdfe64
|
|||
|
31daf2efe0
|
|||
|
6d53fca910
|
|||
|
f7e426e030
|
|||
|
b29e4edd72
|
|||
|
3c58851b88
|
|||
|
99f3540825
|
|||
|
5e778bec30
|
|||
|
fea9d9784d
|
|||
|
23b4a7a069
|
|||
|
89de85c00d
|
|||
|
d892659132
|
|||
|
341e62283b
|
|||
|
61b6c1c55f
|
|||
|
eeaa02bada
|
|||
|
9d16bc2546
|
|||
|
7a52b59b3d
|
|||
|
0ce59a8cc6
|
|||
|
e0dfc0fc3e
|
|||
|
8cb67ca002
|
|||
|
be2a01840c
|
|||
|
612c42ebb7
|
|||
|
e2255a1c85
|
|||
|
0b274b4403
|
|||
|
ddd75f22b0
|
@@ -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
|
||||||
|
|||||||
Vendored
+26
@@ -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
@@ -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
@@ -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" ]
|
||||||
|
|||||||
@@ -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/*
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+82
-37
@@ -1,43 +1,108 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
|
@plugin '@tailwindcss/typography';
|
||||||
|
@plugin '@tailwindcss/forms';
|
||||||
|
@plugin 'flowbite/plugin';
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility min-w-20char {
|
||||||
|
min-width: 20ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility max-w-20char {
|
||||||
|
max-width: 20ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility min-w-30char {
|
||||||
|
min-width: 30ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility max-w-30char {
|
||||||
|
max-width: 30ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility max-w-35char {
|
||||||
|
max-width: 35ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility max-w-40char {
|
||||||
|
max-width: 40ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "IBM Plex Mono";
|
font-family: 'IBM Plex Mono';
|
||||||
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
|
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "IBM Plex Sans";
|
font-family: 'IBM Plex Sans';
|
||||||
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
|
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "IBM Plex Serif";
|
font-family: 'IBM Plex Serif';
|
||||||
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
|
src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "IBM Plex Serif";
|
font-family: 'IBM Plex Serif';
|
||||||
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
|
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "IBM Plex Sans Condensed";
|
font-family: 'IBM Plex Sans Condensed';
|
||||||
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
|
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* a:hover {
|
/* a:hover {
|
||||||
text-decoration-color: #ff4400;
|
text-decoration-color: #ff4400;
|
||||||
color: rgb(254, 185, 160);
|
color: rgb(254, 185, 160);
|
||||||
@@ -53,11 +118,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table tr:nth-child(even) {
|
.responsive-table tr:nth-child(even) {
|
||||||
@apply bg-slate-800
|
@apply bg-indigo-100 dark:bg-slate-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table tbody tr:nth-child(odd) {
|
.responsive-table tbody tr:nth-child(odd) {
|
||||||
@apply bg-slate-900
|
@apply bg-indigo-200 dark:bg-slate-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table thead th {
|
.responsive-table thead th {
|
||||||
@@ -68,26 +133,6 @@
|
|||||||
.responsive-table td:not(:first-child) {
|
.responsive-table td:not(:first-child) {
|
||||||
@apply border-l border-l-slate-500;
|
@apply border-l border-l-slate-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.min-w-20char {
|
|
||||||
min-width: 20ch;
|
|
||||||
}
|
|
||||||
.max-w-20char {
|
|
||||||
max-width: 20ch;
|
|
||||||
}
|
|
||||||
.min-w-30char {
|
|
||||||
min-width: 30ch;
|
|
||||||
}
|
|
||||||
.max-w-30char {
|
|
||||||
max-width: 30ch;
|
|
||||||
}
|
|
||||||
.max-w-35char {
|
|
||||||
max-width: 35ch;
|
|
||||||
}
|
|
||||||
.max-w-40char {
|
|
||||||
max-width: 40ch;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* form input,
|
/* form input,
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+38
-1
@@ -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:
|
||||||
"""
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+76
-5
@@ -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,8 +195,10 @@ class GameForm(forms.ModelForm):
|
|||||||
"name",
|
"name",
|
||||||
"sort_name",
|
"sort_name",
|
||||||
"platform",
|
"platform",
|
||||||
|
"original_year_released",
|
||||||
"year_released",
|
"year_released",
|
||||||
"status",
|
"status",
|
||||||
|
"mastered",
|
||||||
"wikidata",
|
"wikidata",
|
||||||
]
|
]
|
||||||
widgets = {"name": autofocus_input_widget}
|
widgets = {"name": autofocus_input_widget}
|
||||||
@@ -194,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,59 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-03-01 12:52
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('games', '0005_game_mastered_game_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='game',
|
||||||
|
name='sort_name',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='game',
|
||||||
|
name='wikidata',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='platform',
|
||||||
|
name='group',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='purchase',
|
||||||
|
name='converted_currency',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=3),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='purchase',
|
||||||
|
name='games',
|
||||||
|
field=models.ManyToManyField(related_name='purchases', to='games.game'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='purchase',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='purchase',
|
||||||
|
name='related_purchase',
|
||||||
|
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='session',
|
||||||
|
name='game',
|
||||||
|
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='session',
|
||||||
|
name='note',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-03-17 07:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='game',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+176
-47
@@ -1,27 +1,38 @@
|
|||||||
|
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:
|
||||||
unique_together = [["name", "platform", "year_released"]]
|
unique_together = [["name", "platform", "year_released"]]
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
|
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)
|
||||||
wikidata = models.CharField(max_length=50, 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="")
|
||||||
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)
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
UNPLAYED = (
|
UNPLAYED = (
|
||||||
@@ -54,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()
|
||||||
@@ -68,7 +97,7 @@ def get_sentinel_platform():
|
|||||||
|
|
||||||
class Platform(models.Model):
|
class Platform(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
group = models.CharField(max_length=255, null=True, blank=True, default=None)
|
group = models.CharField(max_length=255, blank=True, default="")
|
||||||
icon = models.SlugField(blank=True)
|
icon = models.SlugField(blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
@@ -88,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)
|
||||||
|
|
||||||
@@ -127,33 +153,35 @@ class Purchase(models.Model):
|
|||||||
|
|
||||||
objects = PurchaseQueryset().as_manager()
|
objects = PurchaseQueryset().as_manager()
|
||||||
|
|
||||||
games = models.ManyToManyField(Game, related_name="purchases", blank=True)
|
games = models.ManyToManyField(Game, related_name="purchases")
|
||||||
|
|
||||||
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, null=True)
|
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
|
||||||
)
|
)
|
||||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||||
name = models.CharField(max_length=255, default="", null=True, blank=True)
|
name = models.CharField(max_length=255, blank=True, default="")
|
||||||
related_purchase = models.ForeignKey(
|
related_purchase = models.ForeignKey(
|
||||||
"self",
|
"self",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
|
||||||
related_name="related_purchases",
|
related_name="related_purchases",
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -247,15 +284,27 @@ class Session(models.Model):
|
|||||||
game = models.ForeignKey(
|
game = models.ForeignKey(
|
||||||
Game,
|
Game,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
blank=True,
|
|
||||||
null=True,
|
null=True,
|
||||||
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,
|
||||||
@@ -263,7 +312,7 @@ class Session(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
note = models.TextField(blank=True, null=True)
|
note = models.TextField(blank=True, default="")
|
||||||
emulated = models.BooleanField(default=False)
|
emulated = models.BooleanField(default=False)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -272,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):
|
||||||
@@ -281,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 != None and self.timestamp_start != 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 != None and self.timestamp_end != 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)
|
||||||
|
|
||||||
@@ -352,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"]
|
||||||
|
|||||||
+81
-4
@@ -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):
|
||||||
|
if not reverse and action.startswith("post_"):
|
||||||
instance.num_purchases = instance.games.count()
|
instance.num_purchases = instance.games.count()
|
||||||
instance.updated_at = now()
|
instance.updated_at = now()
|
||||||
instance.save(update_fields=["num_purchases"])
|
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")
|
||||||
|
|||||||
+2658
-2818
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
+28
-26
@@ -1,8 +1,9 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.db.models import ExpressionWrapper, F, FloatField, Q
|
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.utils.timezone import now
|
|
||||||
from django_q.models import Task
|
logger = logging.getLogger("games")
|
||||||
|
|
||||||
from games.models import ExchangeRate, Purchase
|
from games.models import ExchangeRate, Purchase
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ currency_to = currency_to.upper()
|
|||||||
|
|
||||||
|
|
||||||
def save_converted_info(purchase, converted_price, converted_currency):
|
def save_converted_info(purchase, converted_price, converted_currency):
|
||||||
print(
|
logger.info(
|
||||||
f"Changing converted price of {purchase} to {converted_price} {converted_currency} "
|
f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})"
|
||||||
)
|
)
|
||||||
purchase.converted_price = converted_price
|
purchase.converted_price = converted_price
|
||||||
purchase.converted_currency = converted_currency
|
purchase.converted_currency = converted_currency
|
||||||
@@ -22,8 +23,10 @@ def save_converted_info(purchase, converted_price, converted_currency):
|
|||||||
|
|
||||||
def convert_prices():
|
def convert_prices():
|
||||||
purchases = Purchase.objects.filter(
|
purchases = Purchase.objects.filter(
|
||||||
converted_price__isnull=True, converted_currency__isnull=True
|
converted_price__isnull=True, converted_currency=""
|
||||||
)
|
)
|
||||||
|
if purchases.count() == 0:
|
||||||
|
logger.info("[convert_prices]: No prices to convert.")
|
||||||
|
|
||||||
for purchase in purchases:
|
for purchase in purchases:
|
||||||
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
|
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
|
||||||
@@ -31,13 +34,16 @@ def convert_prices():
|
|||||||
continue
|
continue
|
||||||
year = purchase.date_purchased.year
|
year = purchase.date_purchased.year
|
||||||
currency_from = purchase.price_currency.upper()
|
currency_from = purchase.price_currency.upper()
|
||||||
|
|
||||||
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}"
|
||||||
|
)
|
||||||
if not exchange_rate:
|
if not exchange_rate:
|
||||||
print(
|
logger.info(
|
||||||
f"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}..."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# this API endpoint only accepts lowercase currency string
|
# this API endpoint only accepts lowercase currency string
|
||||||
@@ -50,7 +56,7 @@ def convert_prices():
|
|||||||
rate = currency_from_data.get(currency_to.lower())
|
rate = currency_from_data.get(currency_to.lower())
|
||||||
|
|
||||||
if rate:
|
if rate:
|
||||||
print(f"Got {rate}, saving...")
|
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
||||||
exchange_rate = ExchangeRate.objects.create(
|
exchange_rate = ExchangeRate.objects.create(
|
||||||
currency_from=currency_from,
|
currency_from=currency_from,
|
||||||
currency_to=currency_to,
|
currency_to=currency_to,
|
||||||
@@ -58,10 +64,10 @@ def convert_prices():
|
|||||||
rate=floatformat(rate, 2),
|
rate=floatformat(rate, 2),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print("Could not get an exchange rate.")
|
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
print(
|
logger.info(
|
||||||
f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||||
)
|
)
|
||||||
if exchange_rate:
|
if exchange_rate:
|
||||||
save_converted_info(
|
save_converted_info(
|
||||||
@@ -72,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)
|
|
||||||
)
|
|
||||||
print(f"Updating {purchases.count()} purchases.")
|
|
||||||
purchases.update(
|
|
||||||
price_per_game=ExpressionWrapper(
|
|
||||||
F("converted_price") / F("num_purchases"), output_field=FloatField()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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,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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
"> </span>
|
"> </span>
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</div>
|
</span>
|
||||||
@@ -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 |
@@ -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 |
@@ -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,21 +39,33 @@
|
|||||||
<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');
|
// 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 () {
|
themeToggleBtn.addEventListener('click', function () {
|
||||||
|
|
||||||
// toggle icons inside button
|
// toggle icons inside button
|
||||||
themeToggleDarkIcon.classList.toggle('hidden');
|
themeToggleDarkIcon.classList.toggle('hidden');
|
||||||
themeToggleLightIcon.classList.toggle('hidden');
|
themeToggleLightIcon.classList.toggle('hidden');
|
||||||
@@ -59,22 +75,23 @@
|
|||||||
if (localStorage.getItem('color-theme') === 'light') {
|
if (localStorage.getItem('color-theme') === 'light') {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
localStorage.setItem('color-theme', 'dark');
|
localStorage.setItem('color-theme', 'dark');
|
||||||
} else {
|
} else { // current theme is dark, switch to light
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
localStorage.setItem('color-theme', 'light');
|
localStorage.setItem('color-theme', 'light');
|
||||||
}
|
}
|
||||||
|
|
||||||
// if NOT set via local storage previously
|
// if NOT set via local storage previously
|
||||||
} else {
|
} else { // no theme in local storage, use system preference
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
if (document.documentElement.classList.contains('dark')) { // currently dark, switch to light
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
localStorage.setItem('color-theme', 'light');
|
localStorage.setItem('color-theme', 'light');
|
||||||
} else {
|
} else { // currently light, switch to dark
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
localStorage.setItem('color-theme', 'dark');
|
localStorage.setItem('color-theme', 'dark');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
<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 %}
|
||||||
|
{% if purchase.game_name %}
|
||||||
|
<c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name />
|
||||||
{% else %}
|
{% else %}
|
||||||
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
|
<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">
|
||||||
<div class="flex justify-center items-center">
|
<div class="flex justify-center items-center">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %} <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 %} <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,28 +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 }}
|
|
||||||
</c-gamestatus>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<span class="uppercase">Status</span>
|
||||||
|
{% include "partials/gamestatus_selector.html" %}
|
||||||
|
{% if game.mastered %}👑{% endif %}
|
||||||
|
</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>
|
||||||
@@ -95,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() {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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")))["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")))["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": (
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+50
-36
@@ -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 = "^22.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"
|
|
||||||
|
|||||||
@@ -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
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -173,8 +173,9 @@ LOGGING = {
|
|||||||
"loggers": {
|
"loggers": {
|
||||||
"django": {
|
"django": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": "INFO",
|
"level": "WARNING",
|
||||||
},
|
},
|
||||||
|
"games": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user