Compare commits
225 Commits
1.0.3
...
714f0d97a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
714f0d97a9
|
|||
|
d622ddfbf3
|
|||
|
86fd40cc4a
|
|||
|
e174850262
|
|||
|
6328d835ee
|
|||
|
34d42e2af5
|
|||
|
e19caf47bf
|
|||
|
72998ffc02
|
|||
|
ba44814474
|
|||
|
86f8fde8fa
|
|||
|
811fec4b11
|
|||
|
fe6cf2758c
|
|||
|
1e1372ca56
|
|||
|
d91c0bc255
|
|||
|
a14f5d3ae5
|
|||
|
4ac13053d5
|
|||
|
e9311225e7
|
|||
|
44c70a5ee7
|
|||
|
cd804f2c77
|
|||
|
15997bd5af
|
|||
|
880ea93424
|
|||
|
dc1a9d5c4f
|
|||
|
51c25659a9
|
|||
|
973dda59d2
|
|||
|
64edca9ffa
|
|||
|
86e25b84ab
|
|||
|
edc1d062bc
|
|||
|
12a517c9fa
|
|||
|
c1882f66e3
|
|||
|
1e87e67eb1
|
|||
|
84552e088b
|
|||
|
79dc8ae25c
|
|||
|
cee06e4f64
|
|||
|
d9b5f0eab2
|
|||
|
ff28600710
|
|||
|
7517bf5f37
|
|||
|
780a04d13f
|
|||
|
fd04e9fa77
|
|||
|
18902aedac
|
|||
|
f9e37e9b1e
|
|||
|
c747cd1fd8
|
|||
|
6a5457191a
|
|||
|
76f6d0c377
|
|||
|
ae93703c08
|
|||
|
c55176090c
|
|||
|
081b8a92de
|
|||
|
d02a60675f
|
|||
|
4670568acb
|
|||
|
4b75a1dea9
|
|||
|
e2b7ff2e15
|
|||
|
b94aa49fc3
|
|||
|
73a92e5636
|
|||
|
42b28665e1
|
|||
|
6ba187f8e4
|
|||
|
a765fd8d00
|
|||
|
854e3cc54a
|
|||
|
2d8eb32e90
|
|||
|
1f1ed79ee5
|
|||
|
01fd7bad69
|
|||
|
44f49e5974
|
|||
|
0cf3411f63
|
|||
|
aa669710e1
|
|||
|
242833f886
|
|||
|
0cdfd3c298
|
|||
|
a98b4839dd
|
|||
|
1999f13cf2
|
|||
|
8466f67c86
|
|||
|
d9fbb4b896
|
|||
|
4ff3692606
|
|||
|
8289c48896
|
|||
|
d1b9202337
|
|||
|
fde93cb875
|
|||
|
d1c3ac6079
|
|||
|
d921c2d8a6
|
|||
|
52513e1ed8
|
|||
|
cb380814a7
|
|||
|
5ef8c07f30
|
|||
|
9573c3b8ff
|
|||
|
c4354a1380
|
|||
|
a245b6ff0f
|
|||
|
6329d380b7
|
|||
|
76fbc39fed
|
|||
|
4b6734c173
|
|||
|
b505b5b430
|
|||
|
87553ebdc5
|
|||
|
ba4fc0cac5
|
|||
|
8cb0276215
|
|||
|
f9a51ee83d
|
|||
|
c9deba7d65
|
|||
|
c55fbe86b5
|
|||
|
0e93993498
|
|||
|
9fccdfbff0
|
|||
|
d78139a5b3
|
|||
|
7dc43fbf77
|
|||
|
5442926457
|
|||
|
db4c635260
|
|||
|
4a1d08d4df
|
|||
| c35b539c42 | |||
| bbe5e072b2 | |||
| 6fc2f623dc | |||
| 9481bd5fef | |||
| 4083165123 | |||
| 45bb2681c7 | |||
| dbb8ec3f9a | |||
| 206b5f6d46 | |||
| b7e14ecc83 | |||
| 912e010729 | |||
| a485237456 | |||
| f5faf92ee0 | |||
| 07452d8c43 | |||
| 229a79d266 | |||
| c6ed577fe3 | |||
| 171e4779a3 | |||
| 79f94e5984 | |||
| ccebcb89c6 | |||
| fe0a6b39e3 | |||
| 6a495f951f | |||
| c8646d0a0c | |||
| f2bb15e669 | |||
| c49177d63c | |||
| bd8d30eac1 | |||
| c44d8bf427 | |||
| 3f037b4c7c | |||
| 8783d1fc8e | |||
| 9a1d24dbfd | |||
| 4720660cff | |||
| e158bc0623 | |||
| 8982fc5086 | |||
| 729e1d939b | |||
| 2b4683e489 | |||
| cce810e8cf | |||
| 62cd17f702 | |||
| f31280c682 | |||
| a745d16ec3 | |||
| ae079e36ec | |||
| c8a3212b77 | |||
| d211326c3f | |||
| 270a291f05 | |||
| 13b750ca92 | |||
| 015b6db2f7 | |||
| 667b161fff | |||
| 5958cbf4a6 | |||
| 3b37f2c3f0 | |||
| 4517ff2b5a | |||
| 884ce13e26 | |||
| dd219bae9d | |||
| 60d29090a1 | |||
| 1bc3ca057b | |||
| c2c0886451 | |||
| b0be7b5887 | |||
| 099d989f16 | |||
| a879360ebd | |||
| 866f2526e6 | |||
| ce3c4b55f0 | |||
| c52cd822ae | |||
| cdc6ca1324 | |||
| e7ed349356 | |||
| 5052ca7dbf | |||
| f408bfd927 | |||
| 666dee33ba | |||
| e0b09e051a | |||
| 4552cf7616 | |||
| a614b51d29 | |||
| e67aa3fda1 | |||
| 8423fd02b4 | |||
| 2bd07e5f2d | |||
| 058b83522c | |||
| f13ed8a078 | |||
| 02d5adcb3c | |||
| d6fb16bb74 | |||
| 71b90b8202 | |||
| 3ee36932c3 | |||
| 391fcc79a8 | |||
| 57d4fd7212 | |||
| a5b2854bf6 | |||
| 518c0ecd56 | |||
| a6cd7a3430 | |||
| dba8414fd9 | |||
| 0e2113eefd | |||
| c4b0347f3b | |||
| c6ed21167c | |||
| 4ce15c44fc | |||
| c814b4c2cb | |||
| 11b9c602de | |||
| 9a332593f4 | |||
| 22935721ca | |||
| a2ecdcf44a | |||
| 3c958c4a13 | |||
| 3db1724e22 | |||
| d2a9630b04 | |||
| e3ee832d3f | |||
| 7467e2732d | |||
| 787ee8640f | |||
| ab41222f3c | |||
| 29bf3b1946 | |||
| 3f7ccea2e2 | |||
| b5ffb3586b | |||
| 26d57a238e | |||
| 2d5ad3182c | |||
| 49cc3ea0cc | |||
| 440e1cfb71 | |||
| 1cbd8c5c55 | |||
| bc81a0ee8e | |||
| c5653977ff | |||
| f151730ab6 | |||
| f469a67d94 | |||
| 104ffc9d03 | |||
| a4b13eb247 | |||
| 2307fac83a | |||
| 6b52c0d4c4 | |||
| ff5d8c215d | |||
| cdb3b89b08 | |||
| ffa8198540 | |||
| 0b7da3550c | |||
| e1655d6cfa | |||
| 29c41865d0 | |||
| d21b461726 | |||
| 95489cfb78 | |||
| fa4f1c4810 | |||
| 366c25a1ff | |||
| a3042caa20 | |||
| 7997f9bbb2 | |||
|
b78c4ba9c5
|
|||
|
1df889c45d
|
|||
|
468d05a9e2
|
+10
-1
@@ -5,4 +5,13 @@
|
|||||||
.venv
|
.venv
|
||||||
.vscode
|
.vscode
|
||||||
node_modules
|
node_modules
|
||||||
src/timetracker/static/*
|
static
|
||||||
|
.drone.yml
|
||||||
|
.editorconfig
|
||||||
|
.gitignore
|
||||||
|
Caddyfile
|
||||||
|
CHANGELOG.md
|
||||||
|
db.sqlite3
|
||||||
|
docker-compose*
|
||||||
|
Dockerfile
|
||||||
|
Makefile
|
||||||
|
|||||||
+19
-3
@@ -5,23 +5,28 @@ name: default
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: test
|
- name: test
|
||||||
image: python:3.10
|
image: python:3.12
|
||||||
commands:
|
commands:
|
||||||
- python -m pip install poetry
|
- python -m pip install poetry
|
||||||
- poetry install
|
- poetry install
|
||||||
- poetry env info
|
- poetry env info
|
||||||
|
- poetry run python manage.py migrate
|
||||||
- poetry run pytest
|
- poetry run pytest
|
||||||
- name: build container (prod)
|
|
||||||
|
- name: build-prod
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.kucharczyk.xyz/timetracker
|
repo: registry.kucharczyk.xyz/timetracker
|
||||||
tags:
|
tags:
|
||||||
- latest
|
- latest
|
||||||
|
- 1.1.0
|
||||||
|
depends_on:
|
||||||
|
- "test"
|
||||||
when:
|
when:
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
- name: build container (non-prod)
|
- name: build-non-prod
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.kucharczyk.xyz/timetracker
|
repo: registry.kucharczyk.xyz/timetracker
|
||||||
@@ -32,6 +37,17 @@ steps:
|
|||||||
branch:
|
branch:
|
||||||
exclude:
|
exclude:
|
||||||
- main
|
- main
|
||||||
|
depends_on:
|
||||||
|
- "test"
|
||||||
|
|
||||||
|
- name: redeploy on portainer
|
||||||
|
image: plugins/webhook
|
||||||
|
settings:
|
||||||
|
urls:
|
||||||
|
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
|
||||||
|
depends_on:
|
||||||
|
- "build-prod"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.{js,py}]
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
# 4 space indentation
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[**/*.js]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Django CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore: [ 'README.md' ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.12
|
||||||
|
- run: |
|
||||||
|
python -m pip install poetry
|
||||||
|
poetry install
|
||||||
|
poetry env info
|
||||||
|
poetry run python manage.py migrate
|
||||||
|
# PROD=1 poetry run pytest
|
||||||
|
build-and-push:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
registry.kucharczyk.xyz/timetracker:latest
|
||||||
|
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
||||||
|
env:
|
||||||
|
VERSION_NUMBER: 1.5.1
|
||||||
+4
-3
@@ -1,10 +1,11 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.venv
|
.venv/
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
static/admin/
|
/static/
|
||||||
static/django_extensions/
|
|
||||||
dist/
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
.python-version
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 24.3.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.12.0
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
name: isort (python)
|
||||||
|
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||||
|
rev: v1.34.0
|
||||||
|
hooks:
|
||||||
|
- id: djlint-reformat-django
|
||||||
|
- id: djlint-django
|
||||||
Vendored
+4
-1
@@ -4,5 +4,8 @@
|
|||||||
],
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"python.analysis.typeCheckingMode": "basic"
|
"python.analysis.typeCheckingMode": "basic",
|
||||||
|
"[python]": {
|
||||||
|
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
+153
@@ -1,3 +1,156 @@
|
|||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## New
|
||||||
|
* Render notes as Markdown
|
||||||
|
* Require login by default
|
||||||
|
* Add stats for dropped purchases, monthly playtimes
|
||||||
|
* Allow deleting purchases
|
||||||
|
* Add all-time stats
|
||||||
|
|
||||||
|
## Improved
|
||||||
|
* mark refunded purchases red on game overview
|
||||||
|
* increase session count on game overview when starting a new session
|
||||||
|
* game overview:
|
||||||
|
* sort purchases also by date purchased (on top of date released)
|
||||||
|
* improve header format, make it more appealing
|
||||||
|
* ignore manual sessions when calculating session average
|
||||||
|
* stats: improve purchase name consistency
|
||||||
|
* session list: use display name instead of sort name
|
||||||
|
* unify the appearance of game links, and make them expand to full size on hover
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
* Fix title not being displayed on the Recent sessions page
|
||||||
|
* Avoid errors when displaying game overview with zero sessions
|
||||||
|
|
||||||
|
## 1.5.2 / 2024-01-14 21:27+01:00
|
||||||
|
|
||||||
|
## Improved
|
||||||
|
* game overview:
|
||||||
|
* improve how editions and purchases are displayed
|
||||||
|
* make it possible to end session from overview
|
||||||
|
* add purchase: only allow choosing purchases of selected edition
|
||||||
|
* session list:
|
||||||
|
* starting and ending sessions is much faster/doest not reload the page
|
||||||
|
* listing sessions is much faster
|
||||||
|
|
||||||
|
## 1.5.1 / 2023-11-14 21:10+01:00
|
||||||
|
|
||||||
|
## Improved
|
||||||
|
* Disallow choosing non-game purchase as related purchase
|
||||||
|
* Improve display of purchases
|
||||||
|
|
||||||
|
## 1.5.0 / 2023-11-14 19:27+01:00
|
||||||
|
|
||||||
|
## New
|
||||||
|
* Add stat for finished this year's games
|
||||||
|
* Add purchase types:
|
||||||
|
* Game (previously all of them were this type)
|
||||||
|
* DLC
|
||||||
|
* Season Pass
|
||||||
|
* Battle Pass
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
* Order purchases by date on game view
|
||||||
|
|
||||||
|
## 1.4.0 / 2023-11-09 21:01+01:00
|
||||||
|
|
||||||
|
### New
|
||||||
|
* More fields are now optional. This is to make it easier to add new items in bulk.
|
||||||
|
* Game: Wikidata ID
|
||||||
|
* Edition: Platform, Year
|
||||||
|
* Purchase: Platform
|
||||||
|
* Platform: Group
|
||||||
|
* Session: Device
|
||||||
|
* New fields:
|
||||||
|
* Game: Year Released
|
||||||
|
* To record original year of release
|
||||||
|
* Upon migration, this will be set to a year of any of the game's edition that has it set
|
||||||
|
* Purchase: Date Finished
|
||||||
|
* Editions are now unique combination of name and platform
|
||||||
|
* Add more stats:
|
||||||
|
* All finished games
|
||||||
|
* All finished 2023 games
|
||||||
|
* All finished games that were purchased this year
|
||||||
|
* Sessions (count)
|
||||||
|
* Days played
|
||||||
|
* Finished (count)
|
||||||
|
* Unfinished (count)
|
||||||
|
* Refunded (count)
|
||||||
|
* Backlog Decrease (count)
|
||||||
|
* New workflow:
|
||||||
|
* Adding Game, Edition, Purchase, and Session in a row is now much faster
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* game overview: simplify playtime range display
|
||||||
|
* new session: order devices alphabetically
|
||||||
|
* ignore English articles when sorting names
|
||||||
|
* added a new sort_name field that gets automatically created
|
||||||
|
* automatically fill certain values in forms:
|
||||||
|
* new game: name and sort name after typing
|
||||||
|
* new edition: name, sort name, and year when selecting game
|
||||||
|
* new purchase: platform when selecting edition
|
||||||
|
|
||||||
|
## 1.3.0 / 2023-11-05 15:09+01:00
|
||||||
|
|
||||||
|
### New
|
||||||
|
* Add Stats to the main navigation
|
||||||
|
* Allow selecting year on the Stats page
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Make some pages redirect back instead to session list
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Make navigation more compact
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Correctly limit sessions to a single year for stats
|
||||||
|
|
||||||
|
## 1.2.0 / 2023-11-01 20:18+01:00
|
||||||
|
|
||||||
|
### New
|
||||||
|
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
* Add a button to start session from game overview
|
||||||
|
|
||||||
|
## 1.1.2 / 2023-10-13 16:30+02:00
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
* Durations are formatted in a consisent manner across all pages
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
* Game Overview: display duration when >1 hour instead of displaying 0
|
||||||
|
|
||||||
|
## 1.1.1 / 2023-10-09 20:52+02:00
|
||||||
|
|
||||||
|
### New
|
||||||
|
* Add notes section to game overview
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
* Make it possible to add any data on the game overview page
|
||||||
|
|
||||||
|
## 1.1.0 / 2023-10-09 00:01+02:00
|
||||||
|
|
||||||
|
### New
|
||||||
|
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
|
||||||
|
* Add helper buttons next to datime fields
|
||||||
|
* Add copy button on Add session page to copy times between fields
|
||||||
|
* Change fonts to IBM Plex
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
* Improve form appearance
|
||||||
|
* Focus important fields on forms
|
||||||
|
* Use the same form when editing a session as when adding a session
|
||||||
|
* Change recent session view to current year instead of last 30 days
|
||||||
|
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
|
||||||
|
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
* Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58)
|
||||||
|
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
|
||||||
|
|
||||||
|
|
||||||
## 1.0.3 / 2023-02-20 17:16+01:00
|
## 1.0.3 / 2023-02-20 17:16+01:00
|
||||||
|
|
||||||
* Add wikidata ID and year for editions
|
* Add wikidata ID and year for editions
|
||||||
|
|||||||
+31
-20
@@ -1,34 +1,45 @@
|
|||||||
FROM node as css
|
FROM python:3.12.0-slim-bullseye
|
||||||
WORKDIR /app
|
|
||||||
COPY . /app
|
|
||||||
RUN npm install && \
|
|
||||||
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
|
|
||||||
|
|
||||||
FROM python:3.10.9-slim-bullseye
|
ENV VERSION_NUMBER=1.5.2 \
|
||||||
|
PROD=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONFAULTHANDLER=1 \
|
||||||
|
PYTHONHASHSEED=random \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_DEFAULT_TIMEOUT=100 \
|
||||||
|
PIP_ROOT_USER_ACTION=ignore \
|
||||||
|
POETRY_NO_INTERACTION=1 \
|
||||||
|
POETRY_VIRTUALENVS_CREATE=false \
|
||||||
|
POETRY_CACHE_DIR='/var/cache/pypoetry' \
|
||||||
|
POETRY_HOME='/usr/local'
|
||||||
|
|
||||||
ENV VERSION_NUMBER 1.0.3
|
RUN apt-get update && apt-get upgrade -y \
|
||||||
ENV PROD 1
|
&& apt-get install --no-install-recommends -y \
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
RUN apt update && \
|
|
||||||
apt install -y \
|
|
||||||
bash \
|
bash \
|
||||||
vim \
|
curl \
|
||||||
curl && \
|
&& curl -sSL 'https://install.python-poetry.org' | python - \
|
||||||
rm -rf /var/lib/apt/lists/*
|
&& poetry --version \
|
||||||
|
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||||
|
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd -m --uid 1000 timetracker
|
RUN useradd -m --uid 1000 timetracker \
|
||||||
|
&& mkdir -p '/var/www/django/static' \
|
||||||
|
&& chown timetracker:timetracker '/var/www/django/static'
|
||||||
WORKDIR /home/timetracker/app
|
WORKDIR /home/timetracker/app
|
||||||
COPY . /home/timetracker/app/
|
COPY . /home/timetracker/app/
|
||||||
RUN chown -R timetracker:timetracker /home/timetracker/app
|
RUN chown -R timetracker:timetracker /home/timetracker/app
|
||||||
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
|
|
||||||
COPY entrypoint.sh /
|
COPY entrypoint.sh /
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
|
||||||
|
echo "$PROD" \
|
||||||
|
&& poetry version \
|
||||||
|
&& poetry run pip install -U pip \
|
||||||
|
&& poetry install --only main --no-interaction --no-ansi --sync
|
||||||
|
|
||||||
USER timetracker
|
USER timetracker
|
||||||
ENV PATH="$PATH:/home/timetracker/.local/bin"
|
|
||||||
RUN pip install --no-cache-dir poetry
|
|
||||||
RUN poetry install --without dev
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD [ "/entrypoint.sh" ]
|
CMD [ "/entrypoint.sh" ]
|
||||||
@@ -3,6 +3,7 @@ all: css migrate
|
|||||||
initialize: npm css migrate sethookdir loadplatforms
|
initialize: npm css migrate sethookdir loadplatforms
|
||||||
|
|
||||||
HTMLFILES := $(shell find games/templates -type f)
|
HTMLFILES := $(shell find games/templates -type f)
|
||||||
|
PYTHON_VERSION = 3.12
|
||||||
|
|
||||||
npm:
|
npm:
|
||||||
npm install
|
npm install
|
||||||
@@ -10,17 +11,26 @@ npm:
|
|||||||
css: common/input.css
|
css: common/input.css
|
||||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||||
|
|
||||||
css-dev: css
|
|
||||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
|
|
||||||
|
|
||||||
makemigrations:
|
makemigrations:
|
||||||
poetry run python manage.py makemigrations
|
poetry run python manage.py makemigrations
|
||||||
|
|
||||||
migrate: makemigrations
|
migrate: makemigrations
|
||||||
poetry run python manage.py migrate
|
poetry run python manage.py migrate
|
||||||
|
|
||||||
dev: migrate
|
init:
|
||||||
poetry run python manage.py runserver
|
pyenv install -s $(PYTHON_VERSION)
|
||||||
|
pyenv local $(PYTHON_VERSION)
|
||||||
|
pip install poetry
|
||||||
|
poetry install
|
||||||
|
npm install
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@npx concurrently \
|
||||||
|
--names "Django,Tailwind" \
|
||||||
|
--prefix-colors "blue,green" \
|
||||||
|
"poetry run python -Wa manage.py runserver" \
|
||||||
|
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||||
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
caddy run --watch
|
caddy run --watch
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
# Timetracker
|
# Timetracker
|
||||||
|
|
||||||
A simple game catalogue and play session tracker.
|
A simple game catalogue and play session tracker.
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
The project uses `pyenv` to manage installed Python versions.
|
||||||
|
If you have `pyenv` installed, you can simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
make init
|
||||||
|
```
|
||||||
|
|
||||||
|
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
|
||||||
|
Afterwards, you can start the development server using `make dev`.
|
||||||
+141
-14
@@ -2,35 +2,118 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Sans";
|
||||||
|
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Serif";
|
||||||
|
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Serif";
|
||||||
|
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Sans Condensed";
|
||||||
|
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* a:hover {
|
||||||
|
text-decoration-color: #ff4400;
|
||||||
|
color: rgb(254, 185, 160);
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
} */
|
||||||
|
|
||||||
form label {
|
form label {
|
||||||
@apply dark:text-slate-400;
|
@apply dark:text-slate-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.responsive-table {
|
||||||
|
@apply dark:text-white mx-auto table-fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tr:nth-child(even) {
|
||||||
|
@apply bg-slate-800
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tbody tr:nth-child(odd) {
|
||||||
|
@apply bg-slate-900
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table thead th {
|
||||||
|
@apply text-left border-b-2 border-b-slate-500 text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table thead th:not(:first-child),
|
||||||
|
.responsive-table td:not(:first-child) {
|
||||||
|
@apply border-l border-l-slate-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.max-w-20char {
|
||||||
|
max-width: 20ch;
|
||||||
|
}
|
||||||
|
.max-w-30char {
|
||||||
|
max-width: 30ch;
|
||||||
|
}
|
||||||
|
.max-w-35char {
|
||||||
|
max-width: 35ch;
|
||||||
|
}
|
||||||
|
.max-w-40char {
|
||||||
|
max-width: 40ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
form input,
|
form input,
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
#session-table {
|
form input:disabled,
|
||||||
display: grid;
|
select:disabled,
|
||||||
grid-template-columns: 3fr 2fr repeat(2, 1fr) 0.5fr 1fr;
|
textarea:disabled {
|
||||||
|
@apply dark:bg-slate-700 dark:text-slate-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase-name > span:nth-child(2) {
|
.errorlist {
|
||||||
@apply ml-4
|
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase-name > span:nth-child(2) > a > img {
|
@media screen and (min-width: 768px) {
|
||||||
@apply opacity-0 transition-opacity duration-500
|
form input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.purchase-name:hover > span:nth-child(2) > a > img {
|
@media screen and (max-width: 768px) {
|
||||||
@apply opacity-50
|
form input,
|
||||||
}
|
select,
|
||||||
|
textarea {
|
||||||
.purchase-name > span:nth-child(2) > a > img:hover {
|
width: 150px;
|
||||||
@apply opacity-100
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#button-container button {
|
#button-container button {
|
||||||
@@ -38,9 +121,53 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@apply text-left;
|
@apply text-right;
|
||||||
}
|
}
|
||||||
|
|
||||||
th label {
|
th label {
|
||||||
@apply mr-4;
|
@apply mr-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.basic-button-container {
|
||||||
|
@apply flex space-x-2 justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
list-style-position: inside;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
list-style-position: inside;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
list-style-position: outside;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul ul,
|
||||||
|
.markdown-content ul ol,
|
||||||
|
.markdown-content ol ul,
|
||||||
|
.markdown-content ol ol {
|
||||||
|
list-style-type: circle;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .truncate-container {
|
||||||
|
@apply inline-block relative;
|
||||||
|
a {
|
||||||
|
@apply inline-block truncate max-w-20char transition-all 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;
|
||||||
|
|
||||||
|
}
|
||||||
|
} */
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import base64
|
|
||||||
from datetime import datetime
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import matplotlib.dates as mdates
|
|
||||||
import pandas as pd
|
|
||||||
from django.db.models import F, IntegerField, QuerySet, Sum
|
|
||||||
from django.db.models.functions import TruncDay
|
|
||||||
from games.models import Session
|
|
||||||
|
|
||||||
|
|
||||||
def key_value_to_value_value(data):
|
|
||||||
return {data["date"]: data["hours"]}
|
|
||||||
|
|
||||||
|
|
||||||
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
|
|
||||||
microsecond_in_second = 1000000
|
|
||||||
result = (
|
|
||||||
queryset.exclude(timestamp_end__exact=None)
|
|
||||||
.annotate(date=TruncDay("timestamp_start"))
|
|
||||||
.values("date")
|
|
||||||
.annotate(
|
|
||||||
hours=Sum(
|
|
||||||
F("duration_calculated"),
|
|
||||||
output_field=IntegerField(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values("date", "hours")
|
|
||||||
)
|
|
||||||
keys = []
|
|
||||||
values = []
|
|
||||||
running_total = int(0)
|
|
||||||
for item in result:
|
|
||||||
# date_value = datetime.strftime(item["date"], "%d-%m-%Y")
|
|
||||||
date_value = item["date"]
|
|
||||||
keys.append(date_value)
|
|
||||||
running_total += int(item["hours"] / (3600 * microsecond_in_second))
|
|
||||||
values.append(running_total)
|
|
||||||
data = [keys, values]
|
|
||||||
return get_chart(
|
|
||||||
data,
|
|
||||||
title="Playtime over time (manual excluded)",
|
|
||||||
xlabel="Date",
|
|
||||||
ylabel="Hours",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_graph():
|
|
||||||
buffer = BytesIO()
|
|
||||||
plt.savefig(buffer, format="svg", transparent=True)
|
|
||||||
buffer.seek(0)
|
|
||||||
image_png = buffer.getvalue()
|
|
||||||
graph = base64.b64encode(image_png)
|
|
||||||
graph = graph.decode("utf-8")
|
|
||||||
buffer.close()
|
|
||||||
return graph
|
|
||||||
|
|
||||||
|
|
||||||
def get_chart(data, title="", xlabel="", ylabel=""):
|
|
||||||
x = data[0]
|
|
||||||
y = data[1]
|
|
||||||
plt.style.use("dark_background")
|
|
||||||
plt.switch_backend("SVG")
|
|
||||||
fig, ax = plt.subplots()
|
|
||||||
fig.set_size_inches(10, 4)
|
|
||||||
lines = ax.plot(x, y, "-o")
|
|
||||||
first = x[0]
|
|
||||||
last = x[-1]
|
|
||||||
difference = last - first
|
|
||||||
if difference.days <= 14:
|
|
||||||
ax.xaxis.set_major_locator(mdates.DayLocator())
|
|
||||||
elif difference.days < 60 or len(x) < 60:
|
|
||||||
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
|
|
||||||
ax.xaxis.set_minor_locator(mdates.DayLocator())
|
|
||||||
elif difference.days < 720:
|
|
||||||
ax.xaxis.set_major_locator(mdates.MonthLocator())
|
|
||||||
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
|
|
||||||
for line in lines:
|
|
||||||
line.set_marker("")
|
|
||||||
else:
|
|
||||||
for line in lines:
|
|
||||||
line.set_marker("")
|
|
||||||
ax.xaxis.set_major_locator(mdates.YearLocator())
|
|
||||||
ax.xaxis.set_minor_locator(mdates.MonthLocator())
|
|
||||||
|
|
||||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
|
|
||||||
for label in ax.get_xticklabels(which="major"):
|
|
||||||
label.set(rotation=30, horizontalalignment="right")
|
|
||||||
ax.set_xlabel(xlabel)
|
|
||||||
ax.set_ylabel(ylabel)
|
|
||||||
ax.set_title(title)
|
|
||||||
fig.tight_layout()
|
|
||||||
chart = get_graph()
|
|
||||||
return chart
|
|
||||||
+29
-20
@@ -1,12 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
def now() -> datetime:
|
|
||||||
return datetime.now(ZoneInfo(settings.TIME_ZONE))
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_timedelta(duration: timedelta | int | None):
|
def _safe_timedelta(duration: timedelta | int | None):
|
||||||
@@ -32,32 +25,48 @@ def format_duration(
|
|||||||
from the formatting string. For example:
|
from the formatting string. For example:
|
||||||
- 61 seconds as "%s" = 61 seconds
|
- 61 seconds as "%s" = 61 seconds
|
||||||
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
|
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
|
||||||
|
Format specifiers can include width and precision options:
|
||||||
|
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
|
||||||
"""
|
"""
|
||||||
minute_seconds = 60
|
minute_seconds = 60
|
||||||
hour_seconds = 60 * minute_seconds
|
hour_seconds = 60 * minute_seconds
|
||||||
day_seconds = 24 * hour_seconds
|
day_seconds = 24 * hour_seconds
|
||||||
duration = _safe_timedelta(duration)
|
safe_duration = _safe_timedelta(duration)
|
||||||
# we don't need float
|
# we don't need float
|
||||||
seconds_total = int(duration.total_seconds())
|
seconds_total = int(safe_duration.total_seconds())
|
||||||
# timestamps where end is before start
|
# timestamps where end is before start
|
||||||
if seconds_total < 0:
|
if seconds_total < 0:
|
||||||
seconds_total = 0
|
seconds_total = 0
|
||||||
days = hours = minutes = seconds = 0
|
days = hours = hours_float = minutes = seconds = 0
|
||||||
remainder = seconds = seconds_total
|
remainder = seconds = seconds_total
|
||||||
if "%d" in format_string:
|
if "%d" in format_string:
|
||||||
days, remainder = divmod(seconds_total, day_seconds)
|
days, remainder = divmod(seconds_total, day_seconds)
|
||||||
if "%H" in format_string:
|
if re.search(r"%\d*\.?\d*H", format_string):
|
||||||
hours, remainder = divmod(remainder, hour_seconds)
|
hours_float, remainder = divmod(remainder, hour_seconds)
|
||||||
if "%m" in format_string:
|
hours = float(hours_float) + remainder / hour_seconds
|
||||||
|
if re.search(r"%\d*\.?\d*m", format_string):
|
||||||
minutes, seconds = divmod(remainder, minute_seconds)
|
minutes, seconds = divmod(remainder, minute_seconds)
|
||||||
literals = {
|
literals = {
|
||||||
"%d": str(days),
|
"d": str(days),
|
||||||
"%H": str(hours),
|
"H": str(hours) if "m" not in format_string else str(hours_float),
|
||||||
"%m": str(minutes),
|
"m": str(minutes),
|
||||||
"%s": str(seconds),
|
"s": str(seconds),
|
||||||
"%r": str(seconds_total),
|
"r": str(seconds_total),
|
||||||
}
|
}
|
||||||
formatted_string = format_string
|
formatted_string = format_string
|
||||||
for pattern, replacement in literals.items():
|
for pattern, replacement in literals.items():
|
||||||
formatted_string = re.sub(pattern, replacement, formatted_string)
|
# Match format specifiers with optional width and precision
|
||||||
|
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
|
||||||
|
if match:
|
||||||
|
format_spec = match.group(1)
|
||||||
|
if "." in format_spec:
|
||||||
|
# Format the number as float if precision is specified
|
||||||
|
replacement = f"{float(replacement):{format_spec}f}"
|
||||||
|
else:
|
||||||
|
# Format the number as integer if no precision is specified
|
||||||
|
replacement = f"{int(float(replacement)):>{format_spec}}"
|
||||||
|
# Replace the format specifier with the formatted number
|
||||||
|
formatted_string = re.sub(
|
||||||
|
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
|
||||||
|
)
|
||||||
return formatted_string
|
return formatted_string
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
||||||
|
"""
|
||||||
|
Divides without triggering division by zero exception.
|
||||||
|
Returns 0 if denominator is 0.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return numerator / denominator
|
||||||
|
except ZeroDivisionError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def safe_getattr(obj, attr_chain, default=None):
|
||||||
|
"""
|
||||||
|
Safely get the nested attribute from an object.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
obj (object): The object from which to retrieve the attribute.
|
||||||
|
attr_chain (str): The chain of attributes, separated by dots.
|
||||||
|
default: The default value to return if any attribute in the chain does not exist.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The value of the nested attribute if it exists, otherwise the default value.
|
||||||
|
"""
|
||||||
|
attrs = attr_chain.split(".")
|
||||||
|
for attr in attrs:
|
||||||
|
try:
|
||||||
|
obj = getattr(obj, attr)
|
||||||
|
except AttributeError:
|
||||||
|
return default
|
||||||
|
return obj
|
||||||
+4
-2
@@ -10,13 +10,14 @@ services:
|
|||||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||||
user: "1000"
|
user: "1000"
|
||||||
volumes:
|
volumes:
|
||||||
- "static-files:/home/timetracker/app/static"
|
- "static-files:/var/www/django/static"
|
||||||
|
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: caddy
|
image: caddy
|
||||||
volumes:
|
volumes:
|
||||||
- "static-files:/usr/share/caddy"
|
- "static-files:/usr/share/caddy:ro"
|
||||||
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
|
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -26,3 +27,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
static-files:
|
static-files:
|
||||||
|
|
||||||
|
|
||||||
+9
-1
@@ -7,5 +7,13 @@ poetry run python manage.py migrate
|
|||||||
echo "Collect static files"
|
echo "Collect static files"
|
||||||
poetry run python manage.py collectstatic --clear --no-input
|
poetry run python manage.py collectstatic --clear --no-input
|
||||||
|
|
||||||
|
_term() {
|
||||||
|
echo "Caught SIGTERM signal!"
|
||||||
|
kill -SIGTERM "$gunicorn_pid"
|
||||||
|
}
|
||||||
|
trap _term SIGTERM
|
||||||
|
|
||||||
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 -
|
poetry run 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"
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from games.models import Game, Platform, Purchase, Session
|
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
admin.site.register(Game)
|
admin.site.register(Game)
|
||||||
admin.site.register(Purchase)
|
admin.site.register(Purchase)
|
||||||
admin.site.register(Platform)
|
admin.site.register(Platform)
|
||||||
admin.site.register(Session)
|
admin.site.register(Session)
|
||||||
|
admin.site.register(Edition)
|
||||||
|
admin.site.register(Device)
|
||||||
|
|||||||
+114
-7
@@ -1,14 +1,32 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.urls import reverse
|
||||||
|
from common.utils import safe_getattr
|
||||||
|
|
||||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
|
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||||
|
|
||||||
|
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||||
|
custom_datetime_widget = forms.DateTimeInput(
|
||||||
|
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
|
||||||
|
)
|
||||||
|
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||||
|
|
||||||
|
|
||||||
class SessionForm(forms.ModelForm):
|
class SessionForm(forms.ModelForm):
|
||||||
|
# purchase = forms.ModelChoiceField(
|
||||||
|
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
|
||||||
|
# )
|
||||||
purchase = forms.ModelChoiceField(
|
purchase = forms.ModelChoiceField(
|
||||||
queryset=Purchase.objects.order_by("edition__name")
|
queryset=Purchase.objects.order_by("edition__sort_name"),
|
||||||
|
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
widgets = {
|
||||||
|
"timestamp_start": custom_datetime_widget,
|
||||||
|
"timestamp_end": custom_datetime_widget,
|
||||||
|
}
|
||||||
model = Session
|
model = Session
|
||||||
fields = [
|
fields = [
|
||||||
"purchase",
|
"purchase",
|
||||||
@@ -22,47 +40,136 @@ class SessionForm(forms.ModelForm):
|
|||||||
|
|
||||||
class EditionChoiceField(forms.ModelChoiceField):
|
class EditionChoiceField(forms.ModelChoiceField):
|
||||||
def label_from_instance(self, obj) -> str:
|
def label_from_instance(self, obj) -> str:
|
||||||
return f"{obj.name} ({obj.platform}, {obj.year_released})"
|
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||||
|
|
||||||
|
|
||||||
|
class IncludePlatformSelect(forms.Select):
|
||||||
|
def create_option(self, name, value, *args, **kwargs):
|
||||||
|
option = super().create_option(name, value, *args, **kwargs)
|
||||||
|
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||||
|
option["attrs"]["data-platform"] = platform_id
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
class PurchaseForm(forms.ModelForm):
|
class PurchaseForm(forms.ModelForm):
|
||||||
edition = EditionChoiceField(queryset=Edition.objects.order_by("name"))
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Automatically update related_purchase <select/>
|
||||||
|
# to only include purchases of the selected edition.
|
||||||
|
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
|
||||||
|
self.fields["edition"].widget.attrs.update(
|
||||||
|
{
|
||||||
|
"hx-trigger": "load, click",
|
||||||
|
"hx-get": related_purchase_by_edition_url,
|
||||||
|
"hx-target": "#id_related_purchase",
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
edition = EditionChoiceField(
|
||||||
|
queryset=Edition.objects.order_by("sort_name"),
|
||||||
|
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||||
|
)
|
||||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||||
|
related_purchase = forms.ModelChoiceField(
|
||||||
|
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
|
||||||
|
"edition__sort_name"
|
||||||
|
),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
widgets = {
|
||||||
|
"date_purchased": custom_date_widget,
|
||||||
|
"date_refunded": custom_date_widget,
|
||||||
|
"date_finished": custom_date_widget,
|
||||||
|
"date_dropped": custom_date_widget,
|
||||||
|
}
|
||||||
model = Purchase
|
model = Purchase
|
||||||
fields = [
|
fields = [
|
||||||
"edition",
|
"edition",
|
||||||
"platform",
|
"platform",
|
||||||
"date_purchased",
|
"date_purchased",
|
||||||
"date_refunded",
|
"date_refunded",
|
||||||
|
"date_finished",
|
||||||
|
"date_dropped",
|
||||||
|
"infinite",
|
||||||
"price",
|
"price",
|
||||||
"price_currency",
|
"price_currency",
|
||||||
"ownership_type",
|
"ownership_type",
|
||||||
|
"type",
|
||||||
|
"related_purchase",
|
||||||
|
"name",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
purchase_type = cleaned_data.get("type")
|
||||||
|
related_purchase = cleaned_data.get("related_purchase")
|
||||||
|
name = cleaned_data.get("name")
|
||||||
|
|
||||||
|
# Set the type on the instance to use get_type_display()
|
||||||
|
# This is safe because we're not saving the instance.
|
||||||
|
self.instance.type = purchase_type
|
||||||
|
|
||||||
|
if purchase_type != Purchase.GAME:
|
||||||
|
type_display = self.instance.get_type_display()
|
||||||
|
if not related_purchase:
|
||||||
|
self.add_error(
|
||||||
|
"related_purchase",
|
||||||
|
f"{type_display} must have a related purchase.",
|
||||||
|
)
|
||||||
|
if not name:
|
||||||
|
self.add_error("name", f"{type_display} must have a name.")
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class IncludeNameSelect(forms.Select):
|
||||||
|
def create_option(self, name, value, *args, **kwargs):
|
||||||
|
option = super().create_option(name, value, *args, **kwargs)
|
||||||
|
if value:
|
||||||
|
option["attrs"]["data-name"] = value.instance.name
|
||||||
|
option["attrs"]["data-year"] = value.instance.year_released
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
class GameModelChoiceField(forms.ModelChoiceField):
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
# Use sort_name as the label for the option
|
||||||
|
return obj.sort_name
|
||||||
|
|
||||||
|
|
||||||
class EditionForm(forms.ModelForm):
|
class EditionForm(forms.ModelForm):
|
||||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
game = GameModelChoiceField(
|
||||||
|
queryset=Game.objects.order_by("sort_name"),
|
||||||
|
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
|
||||||
|
)
|
||||||
|
platform = forms.ModelChoiceField(
|
||||||
|
queryset=Platform.objects.order_by("name"), required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Edition
|
model = Edition
|
||||||
fields = ["game", "name", "platform", "year_released", "wikidata"]
|
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
|
||||||
|
|
||||||
|
|
||||||
class GameForm(forms.ModelForm):
|
class GameForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Game
|
model = Game
|
||||||
fields = ["name", "wikidata"]
|
fields = ["name", "sort_name", "year_released", "wikidata"]
|
||||||
|
widgets = {"name": autofocus_input_widget}
|
||||||
|
|
||||||
|
|
||||||
class PlatformForm(forms.ModelForm):
|
class PlatformForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = ["name", "group"]
|
fields = ["name", "group"]
|
||||||
|
widgets = {"name": autofocus_input_widget}
|
||||||
|
|
||||||
|
|
||||||
class DeviceForm(forms.ModelForm):
|
class DeviceForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = ["name", "type"]
|
fields = ["name", "type"]
|
||||||
|
widgets = {"name": autofocus_input_widget}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from .game import Mutation as GameMutation
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.types import Game
|
||||||
|
from games.models import Game as GameModel
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateGameMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
id = graphene.ID(required=True)
|
||||||
|
name = graphene.String()
|
||||||
|
year_released = graphene.Int()
|
||||||
|
wikidata = graphene.String()
|
||||||
|
|
||||||
|
game = graphene.Field(Game)
|
||||||
|
|
||||||
|
def mutate(self, info, id, name=None, year_released=None, wikidata=None):
|
||||||
|
game_instance = GameModel.objects.get(pk=id)
|
||||||
|
if name is not None:
|
||||||
|
game_instance.name = name
|
||||||
|
if year_released is not None:
|
||||||
|
game_instance.year_released = year_released
|
||||||
|
if wikidata is not None:
|
||||||
|
game_instance.wikidata = wikidata
|
||||||
|
game_instance.save()
|
||||||
|
return UpdateGameMutation(game=game_instance)
|
||||||
|
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
update_game = UpdateGameMutation.Field()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from .device import Query as DeviceQuery
|
||||||
|
from .edition import Query as EditionQuery
|
||||||
|
from .game import Query as GameQuery
|
||||||
|
from .platform import Query as PlatformQuery
|
||||||
|
from .purchase import Query as PurchaseQuery
|
||||||
|
from .session import Query as SessionQuery
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.types import Device
|
||||||
|
from games.models import Device as DeviceModel
|
||||||
|
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
devices = graphene.List(Device)
|
||||||
|
|
||||||
|
def resolve_devices(self, info, **kwargs):
|
||||||
|
return DeviceModel.objects.all()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.types import Edition
|
||||||
|
from games.models import Game as EditionModel
|
||||||
|
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
editions = graphene.List(Edition)
|
||||||
|
|
||||||
|
def resolve_editions(self, info, **kwargs):
|
||||||
|
return EditionModel.objects.all()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.types import Game
|
||||||
|
from games.models import Game as GameModel
|
||||||
|
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
games = graphene.List(Game)
|
||||||
|
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
|
||||||
|
|
||||||
|
def resolve_games(self, info, **kwargs):
|
||||||
|
return GameModel.objects.all()
|
||||||
|
|
||||||
|
def resolve_game_by_name(self, info, name):
|
||||||
|
try:
|
||||||
|
return GameModel.objects.get(name=name)
|
||||||
|
except GameModel.DoesNotExist:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.types import Platform
|
||||||
|
from games.models import Platform as PlatformModel
|
||||||
|
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
platforms = graphene.List(Platform)
|
||||||
|
|
||||||
|
def resolve_platforms(self, info, **kwargs):
|
||||||
|
return PlatformModel.objects.all()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.types import Purchase
|
||||||
|
from games.models import Purchase as PurchaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
purchases = graphene.List(Purchase)
|
||||||
|
|
||||||
|
def resolve_purchases(self, info, **kwargs):
|
||||||
|
return PurchaseModel.objects.all()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.types import Session
|
||||||
|
from games.models import Session as SessionModel
|
||||||
|
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
sessions = graphene.List(Session)
|
||||||
|
|
||||||
|
def resolve_sessions(self, info, **kwargs):
|
||||||
|
return SessionModel.objects.all()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
|
||||||
|
from games.models import Device as DeviceModel
|
||||||
|
from games.models import Edition as EditionModel
|
||||||
|
from games.models import Game as GameModel
|
||||||
|
from games.models import Platform as PlatformModel
|
||||||
|
from games.models import Purchase as PurchaseModel
|
||||||
|
from games.models import Session as SessionModel
|
||||||
|
|
||||||
|
|
||||||
|
class Game(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = GameModel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class Edition(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = EditionModel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class Purchase(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = PurchaseModel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class Session(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = SessionModel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class Platform(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = PlatformModel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class Device(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = DeviceModel
|
||||||
|
fields = "__all__"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 4.1.5 on 2023-01-19 18:30
|
# Generated by Django 4.1.5 on 2023-01-19 18:30
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 4.1.5 on 2023-02-18 16:29
|
# Generated by Django 4.1.5 on 2023-02-18 16:29
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 4.1.5 on 2023-02-18 19:06
|
# Generated by Django 4.1.5 on 2023-02-18 19:06
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 4.1.5 on 2023-02-18 19:59
|
# Generated by Django 4.1.5 on 2023-02-18 19:59
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 11:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0015_edition_wikidata_edition_year_released"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edition",
|
||||||
|
name="platform",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="games.platform",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edition",
|
||||||
|
name="year_released",
|
||||||
|
field=models.IntegerField(blank=True, default=None, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="game",
|
||||||
|
name="wikidata",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="platform",
|
||||||
|
name="group",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="session",
|
||||||
|
name="device",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="games.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 18:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def rename_duplicates(apps, schema_editor):
|
||||||
|
Edition = apps.get_model("games", "Edition")
|
||||||
|
|
||||||
|
duplicates = (
|
||||||
|
Edition.objects.values("name", "platform")
|
||||||
|
.annotate(name_count=models.Count("id"))
|
||||||
|
.filter(name_count__gt=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
for duplicate in duplicates:
|
||||||
|
counter = 1
|
||||||
|
duplicate_editions = Edition.objects.filter(
|
||||||
|
name=duplicate["name"], platform_id=duplicate["platform"]
|
||||||
|
).order_by("id")
|
||||||
|
|
||||||
|
for edition in duplicate_editions[1:]: # Skip the first one
|
||||||
|
edition.name = f"{edition.name} {counter}"
|
||||||
|
edition.save()
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
def update_game_year(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Edition = apps.get_model("games", "Edition")
|
||||||
|
|
||||||
|
for game in Game.objects.filter(year__isnull=True):
|
||||||
|
# Try to get the first related edition with a non-null year_released
|
||||||
|
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
|
||||||
|
if edition:
|
||||||
|
# If an edition is found, update the game's year
|
||||||
|
game.year = edition.year_released
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
replaces = [
|
||||||
|
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
|
||||||
|
("games", "0017_alter_device_type_alter_purchase_platform"),
|
||||||
|
("games", "0018_auto_20231106_1825"),
|
||||||
|
("games", "0019_alter_edition_unique_together"),
|
||||||
|
("games", "0020_game_year"),
|
||||||
|
("games", "0021_auto_20231106_1909"),
|
||||||
|
("games", "0022_rename_year_game_year_released"),
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0015_edition_wikidata_edition_year_released"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edition",
|
||||||
|
name="platform",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="games.platform",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edition",
|
||||||
|
name="year_released",
|
||||||
|
field=models.IntegerField(blank=True, default=None, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="game",
|
||||||
|
name="wikidata",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="platform",
|
||||||
|
name="group",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="session",
|
||||||
|
name="device",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="games.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="device",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pc", "PC"),
|
||||||
|
("co", "Console"),
|
||||||
|
("ha", "Handheld"),
|
||||||
|
("mo", "Mobile"),
|
||||||
|
("sbc", "Single-board computer"),
|
||||||
|
("un", "Unknown"),
|
||||||
|
],
|
||||||
|
default="un",
|
||||||
|
max_length=3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="platform",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="games.platform",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=rename_duplicates,
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="edition",
|
||||||
|
unique_together={("name", "platform")},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="year",
|
||||||
|
field=models.IntegerField(blank=True, default=None, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=update_game_year,
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="game",
|
||||||
|
old_name="year",
|
||||||
|
new_name="year_released",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 16:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="device",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pc", "PC"),
|
||||||
|
("co", "Console"),
|
||||||
|
("ha", "Handheld"),
|
||||||
|
("mo", "Mobile"),
|
||||||
|
("sbc", "Single-board computer"),
|
||||||
|
("un", "Unknown"),
|
||||||
|
],
|
||||||
|
default="un",
|
||||||
|
max_length=3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="platform",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="games.platform",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 17:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def rename_duplicates(apps, schema_editor):
|
||||||
|
Edition = apps.get_model("games", "Edition")
|
||||||
|
|
||||||
|
duplicates = (
|
||||||
|
Edition.objects.values("name", "platform")
|
||||||
|
.annotate(name_count=models.Count("id"))
|
||||||
|
.filter(name_count__gt=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
for duplicate in duplicates:
|
||||||
|
counter = 1
|
||||||
|
duplicate_editions = Edition.objects.filter(
|
||||||
|
name=duplicate["name"], platform_id=duplicate["platform"]
|
||||||
|
).order_by("id")
|
||||||
|
|
||||||
|
for edition in duplicate_editions[1:]: # Skip the first one
|
||||||
|
edition.name = f"{edition.name} {counter}"
|
||||||
|
edition.save()
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0017_alter_device_type_alter_purchase_platform"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_duplicates),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 17:26
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0018_auto_20231106_1825"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="edition",
|
||||||
|
unique_together={("name", "platform")},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 18:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0019_alter_edition_unique_together"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="year",
|
||||||
|
field=models.IntegerField(blank=True, default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def update_game_year(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Edition = apps.get_model("games", "Edition")
|
||||||
|
|
||||||
|
for game in Game.objects.filter(year__isnull=True):
|
||||||
|
# Try to get the first related edition with a non-null year_released
|
||||||
|
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
|
||||||
|
if edition:
|
||||||
|
# If an edition is found, update the game's year
|
||||||
|
game.year = edition.year_released
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0020_game_year"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(update_game_year),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 18:12
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0021_auto_20231106_1909"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="game",
|
||||||
|
old_name="year",
|
||||||
|
new_name="year_released",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-06 18:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"games",
|
||||||
|
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="date_finished",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-09 09:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_sort_name(apps, schema_editor):
|
||||||
|
Edition = apps.get_model(
|
||||||
|
"games", "Edition"
|
||||||
|
) # Replace 'your_app_name' with the actual name of your app
|
||||||
|
|
||||||
|
for edition in Edition.objects.all():
|
||||||
|
name = edition.name
|
||||||
|
# Check for articles at the beginning of the name and move them to the end
|
||||||
|
if name.lower().startswith("the "):
|
||||||
|
sort_name = f"{name[4:]}, The"
|
||||||
|
elif name.lower().startswith("a "):
|
||||||
|
sort_name = f"{name[2:]}, A"
|
||||||
|
elif name.lower().startswith("an "):
|
||||||
|
sort_name = f"{name[3:]}, An"
|
||||||
|
else:
|
||||||
|
sort_name = name
|
||||||
|
# Save the sort_name back to the database
|
||||||
|
edition.sort_name = sort_name
|
||||||
|
edition.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0023_purchase_date_finished"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="edition",
|
||||||
|
name="sort_name",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_sort_name),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-09 09:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_sort_name(apps, schema_editor):
|
||||||
|
Game = apps.get_model(
|
||||||
|
"games", "Game"
|
||||||
|
) # Replace 'your_app_name' with the actual name of your app
|
||||||
|
|
||||||
|
for game in Game.objects.all():
|
||||||
|
name = game.name
|
||||||
|
# Check for articles at the beginning of the name and move them to the end
|
||||||
|
if name.lower().startswith("the "):
|
||||||
|
sort_name = f"{name[4:]}, The"
|
||||||
|
elif name.lower().startswith("a "):
|
||||||
|
sort_name = f"{name[2:]}, A"
|
||||||
|
elif name.lower().startswith("an "):
|
||||||
|
sort_name = f"{name[3:]}, An"
|
||||||
|
else:
|
||||||
|
sort_name = name
|
||||||
|
# Save the sort_name back to the database
|
||||||
|
game.sort_name = sort_name
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0024_edition_sort_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="sort_name",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_sort_name),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-14 08:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0025_game_sort_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("game", "Game"),
|
||||||
|
("dlc", "DLC"),
|
||||||
|
("season_pass", "Season Pass"),
|
||||||
|
("battle_pass", "Battle Pass"),
|
||||||
|
],
|
||||||
|
default="game",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-14 08:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0026_purchase_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="related_purchase",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="games.purchase",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-14 11:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
|
||||||
|
def null_game_name(apps, schema_editor):
|
||||||
|
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0027_purchase_related_purchase"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, default="Unknown Name", max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(null_game_name),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-14 21:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0028_purchase_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="related_purchase",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="related_purchases",
|
||||||
|
to="games.purchase",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-15 12:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0029_alter_purchase_related_purchase"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-15 13:51
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0030_alter_purchase_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="device",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="edition",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="platform",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="session",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-15 18:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="session",
|
||||||
|
options={"get_latest_by": "timestamp_start"},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="session",
|
||||||
|
name="modified_at",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="device",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edition",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="game",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="platform",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="session",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2023-11-28 13:43
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0032_alter_session_options_session_modified_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="edition",
|
||||||
|
unique_together={("name", "platform", "year_released")},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2024-01-03 21:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0033_alter_edition_unique_together"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="date_dropped",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="infinite",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
+139
-25
@@ -1,30 +1,54 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from common.time import format_duration
|
from django.core.exceptions import ValidationError
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F, Manager, Sum
|
from django.db.models import F, Manager, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from common.time import format_duration
|
||||||
|
|
||||||
|
|
||||||
class Game(models.Model):
|
class Game(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
wikidata = models.CharField(max_length=50)
|
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||||
|
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||||
|
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Edition(models.Model):
|
class Edition(models.Model):
|
||||||
|
class Meta:
|
||||||
|
unique_together = [["name", "platform", "year_released"]]
|
||||||
|
|
||||||
game = models.ForeignKey("Game", on_delete=models.CASCADE)
|
game = models.ForeignKey("Game", on_delete=models.CASCADE)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
|
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||||
year_released = models.IntegerField(default=datetime.today().year)
|
platform = models.ForeignKey(
|
||||||
|
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
|
||||||
|
)
|
||||||
|
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||||
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.sort_name
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseQueryset(models.QuerySet):
|
||||||
|
def refunded(self):
|
||||||
|
return self.filter(date_refunded__isnull=False)
|
||||||
|
|
||||||
|
def not_refunded(self):
|
||||||
|
return self.filter(date_refunded__isnull=True)
|
||||||
|
|
||||||
|
def finished(self):
|
||||||
|
return self.filter(date_finished__isnull=False)
|
||||||
|
|
||||||
|
def games_only(self):
|
||||||
|
return self.filter(type=Purchase.GAME)
|
||||||
|
|
||||||
|
|
||||||
class Purchase(models.Model):
|
class Purchase(models.Model):
|
||||||
@@ -46,83 +70,170 @@ class Purchase(models.Model):
|
|||||||
(DEMO, "Demo"),
|
(DEMO, "Demo"),
|
||||||
(PIRATED, "Pirated"),
|
(PIRATED, "Pirated"),
|
||||||
]
|
]
|
||||||
|
GAME = "game"
|
||||||
|
DLC = "dlc"
|
||||||
|
SEASONPASS = "season_pass"
|
||||||
|
BATTLEPASS = "battle_pass"
|
||||||
|
TYPES = [
|
||||||
|
(GAME, "Game"),
|
||||||
|
(DLC, "DLC"),
|
||||||
|
(SEASONPASS, "Season Pass"),
|
||||||
|
(BATTLEPASS, "Battle Pass"),
|
||||||
|
]
|
||||||
|
|
||||||
|
objects = PurchaseQueryset().as_manager()
|
||||||
|
|
||||||
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
|
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
|
||||||
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
|
platform = models.ForeignKey(
|
||||||
|
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
|
||||||
|
)
|
||||||
date_purchased = models.DateField()
|
date_purchased = models.DateField()
|
||||||
date_refunded = models.DateField(blank=True, null=True)
|
date_refunded = models.DateField(blank=True, null=True)
|
||||||
|
date_finished = models.DateField(blank=True, null=True)
|
||||||
|
date_dropped = models.DateField(blank=True, null=True)
|
||||||
|
infinite = models.BooleanField(default=False)
|
||||||
price = models.IntegerField(default=0)
|
price = models.IntegerField(default=0)
|
||||||
price_currency = models.CharField(max_length=3, default="USD")
|
price_currency = models.CharField(max_length=3, default="USD")
|
||||||
ownership_type = models.CharField(
|
ownership_type = models.CharField(
|
||||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||||
)
|
)
|
||||||
|
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||||
|
name = models.CharField(max_length=255, default="", null=True, blank=True)
|
||||||
|
related_purchase = models.ForeignKey(
|
||||||
|
"Purchase",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="related_purchases",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
platform_info = self.platform
|
additional_info = [
|
||||||
if self.platform != self.edition.platform:
|
self.get_type_display() if self.type != Purchase.GAME else "",
|
||||||
platform_info = f"{self.edition.platform} version on {self.platform}"
|
(
|
||||||
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
|
f"{self.edition.platform} version on {self.platform}"
|
||||||
|
if self.platform != self.edition.platform
|
||||||
|
else self.platform
|
||||||
|
),
|
||||||
|
self.edition.year_released,
|
||||||
|
self.get_ownership_type_display(),
|
||||||
|
]
|
||||||
|
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
|
||||||
|
|
||||||
|
def is_game(self):
|
||||||
|
return self.type == self.GAME
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.type == Purchase.GAME:
|
||||||
|
self.name = ""
|
||||||
|
elif self.type != Purchase.GAME and not self.related_purchase:
|
||||||
|
raise ValidationError(
|
||||||
|
f"{self.get_type_display()} must have a related purchase."
|
||||||
|
)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Platform(models.Model):
|
class Platform(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
group = models.CharField(max_length=255)
|
group = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class SessionQuerySet(models.QuerySet):
|
class SessionQuerySet(models.QuerySet):
|
||||||
def total_duration(self):
|
def total_duration_formatted(self):
|
||||||
|
return format_duration(self.total_duration_unformatted())
|
||||||
|
|
||||||
|
def total_duration_unformatted(self):
|
||||||
result = self.aggregate(
|
result = self.aggregate(
|
||||||
duration=Sum(F("duration_calculated") + F("duration_manual"))
|
duration=Sum(F("duration_calculated") + F("duration_manual"))
|
||||||
)
|
)
|
||||||
return format_duration(result["duration"])
|
return result["duration"]
|
||||||
|
|
||||||
|
def calculated_duration_formatted(self):
|
||||||
|
return format_duration(self.calculated_duration_unformatted())
|
||||||
|
|
||||||
|
def calculated_duration_unformatted(self):
|
||||||
|
result = self.aggregate(duration=Sum(F("duration_calculated")))
|
||||||
|
return result["duration"]
|
||||||
|
|
||||||
|
def without_manual(self):
|
||||||
|
return self.exclude(duration_calculated__iexact=0)
|
||||||
|
|
||||||
|
def only_manual(self):
|
||||||
|
return self.filter(duration_calculated__iexact=0)
|
||||||
|
|
||||||
|
|
||||||
class Session(models.Model):
|
class Session(models.Model):
|
||||||
|
class Meta:
|
||||||
|
get_latest_by = "timestamp_start"
|
||||||
|
|
||||||
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
|
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
|
||||||
timestamp_start = models.DateTimeField()
|
timestamp_start = models.DateTimeField()
|
||||||
timestamp_end = models.DateTimeField(blank=True, null=True)
|
timestamp_end = models.DateTimeField(blank=True, null=True)
|
||||||
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
|
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
|
||||||
duration_calculated = models.DurationField(blank=True, null=True)
|
duration_calculated = models.DurationField(blank=True, null=True)
|
||||||
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
|
device = models.ForeignKey(
|
||||||
|
"Device",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
note = models.TextField(blank=True, null=True)
|
note = models.TextField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
modified_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
objects = SessionQuerySet.as_manager()
|
objects = SessionQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
mark = ", manual" if self.duration_manual != None else ""
|
mark = ", manual" if self.is_manual() else ""
|
||||||
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||||
|
|
||||||
def finish_now(self):
|
def finish_now(self):
|
||||||
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
self.timestamp_end = timezone.now()
|
||||||
|
|
||||||
def start_now():
|
def start_now():
|
||||||
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
self.timestamp_start = timezone.now()
|
||||||
|
|
||||||
def duration_seconds(self) -> timedelta:
|
def duration_seconds(self) -> timedelta:
|
||||||
manual = timedelta(0)
|
manual = timedelta(0)
|
||||||
calculated = timedelta(0)
|
calculated = timedelta(0)
|
||||||
if not self.duration_manual in (None, 0, timedelta(0)):
|
if self.is_manual() and isinstance(self.duration_manual, timedelta):
|
||||||
manual = self.duration_manual
|
manual = self.duration_manual
|
||||||
if self.timestamp_end != None and self.timestamp_start != None:
|
if self.timestamp_end != None and self.timestamp_start != None:
|
||||||
calculated = self.timestamp_end - self.timestamp_start
|
calculated = self.timestamp_end - self.timestamp_start
|
||||||
return timedelta(seconds=(manual + calculated).total_seconds())
|
return timedelta(seconds=(manual + calculated).total_seconds())
|
||||||
|
|
||||||
def duration_formatted(self) -> str:
|
def duration_formatted(self) -> str:
|
||||||
result = format_duration(self.duration_seconds(), "%H:%m")
|
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def is_manual(self) -> bool:
|
||||||
|
return not self.duration_manual == timedelta(0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration_sum(self) -> str:
|
def duration_sum(self) -> str:
|
||||||
return Session.objects.all().total_duration()
|
return Session.objects.all().total_duration_formatted()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.timestamp_start != None and self.timestamp_end != None:
|
if self.timestamp_start != None and self.timestamp_end != None:
|
||||||
self.duration_calculated = self.timestamp_end - self.timestamp_start
|
self.duration_calculated = self.timestamp_end - self.timestamp_start
|
||||||
else:
|
else:
|
||||||
self.duration_calculated = timedelta(0)
|
self.duration_calculated = timedelta(0)
|
||||||
|
|
||||||
|
if not isinstance(self.duration_manual, timedelta):
|
||||||
|
self.duration_manual = timedelta(0)
|
||||||
|
|
||||||
|
if not self.device:
|
||||||
|
default_device, _ = Device.objects.get_or_create(
|
||||||
|
type=Device.UNKNOWN, defaults={"name": "Unknown"}
|
||||||
|
)
|
||||||
|
self.device = default_device
|
||||||
super(Session, self).save(*args, **kwargs)
|
super(Session, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,15 +243,18 @@ class Device(models.Model):
|
|||||||
HANDHELD = "ha"
|
HANDHELD = "ha"
|
||||||
MOBILE = "mo"
|
MOBILE = "mo"
|
||||||
SBC = "sbc"
|
SBC = "sbc"
|
||||||
|
UNKNOWN = "un"
|
||||||
DEVICE_TYPES = [
|
DEVICE_TYPES = [
|
||||||
(PC, "PC"),
|
(PC, "PC"),
|
||||||
(CONSOLE, "Console"),
|
(CONSOLE, "Console"),
|
||||||
(HANDHELD, "Handheld"),
|
(HANDHELD, "Handheld"),
|
||||||
(MOBILE, "Mobile"),
|
(MOBILE, "Mobile"),
|
||||||
(SBC, "Single-board computer"),
|
(SBC, "Single-board computer"),
|
||||||
|
(UNKNOWN, "Unknown"),
|
||||||
]
|
]
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
|
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.get_type_display()})"
|
return f"{self.name} ({self.get_type_display()})"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
from games.graphql.mutations import GameMutation
|
||||||
|
from games.graphql.queries import (
|
||||||
|
DeviceQuery,
|
||||||
|
EditionQuery,
|
||||||
|
GameQuery,
|
||||||
|
PlatformQuery,
|
||||||
|
PurchaseQuery,
|
||||||
|
SessionQuery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Query(
|
||||||
|
GameQuery,
|
||||||
|
EditionQuery,
|
||||||
|
DeviceQuery,
|
||||||
|
PlatformQuery,
|
||||||
|
PurchaseQuery,
|
||||||
|
SessionQuery,
|
||||||
|
graphene.ObjectType,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Mutation(GameMutation, graphene.ObjectType):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query, mutation=Mutation)
|
||||||
+1403
-4044
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 787 B |
Binary file not shown.
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1,24 @@
|
|||||||
|
import { syncSelectInputUntilChanged } from "./utils.js";
|
||||||
|
|
||||||
|
let syncData = [
|
||||||
|
{
|
||||||
|
source: "#id_game",
|
||||||
|
source_value: "dataset.name",
|
||||||
|
target: "#id_name",
|
||||||
|
target_value: "value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "#id_game",
|
||||||
|
source_value: "textContent",
|
||||||
|
target: "#id_sort_name",
|
||||||
|
target_value: "value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "#id_game",
|
||||||
|
source_value: "dataset.year",
|
||||||
|
target: "#id_year_released",
|
||||||
|
target_value: "value",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
syncSelectInputUntilChanged(syncData, "form");
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { syncSelectInputUntilChanged } from "./utils.js";
|
||||||
|
|
||||||
|
let syncData = [
|
||||||
|
{
|
||||||
|
source: "#id_name",
|
||||||
|
source_value: "value",
|
||||||
|
target: "#id_sort_name",
|
||||||
|
target_value: "value",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
syncSelectInputUntilChanged(syncData, "form");
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
syncSelectInputUntilChanged,
|
||||||
|
getEl,
|
||||||
|
disableElementsWhenTrue,
|
||||||
|
disableElementsWhenValueNotEqual,
|
||||||
|
} from "./utils.js";
|
||||||
|
|
||||||
|
let syncData = [
|
||||||
|
{
|
||||||
|
source: "#id_edition",
|
||||||
|
source_value: "dataset.platform",
|
||||||
|
target: "#id_platform",
|
||||||
|
target_value: "value",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
syncSelectInputUntilChanged(syncData, "form");
|
||||||
|
|
||||||
|
function setupElementHandlers() {
|
||||||
|
disableElementsWhenTrue("#id_type", "game", [
|
||||||
|
"#id_name",
|
||||||
|
"#id_related_purchase",
|
||||||
|
]);
|
||||||
|
disableElementsWhenValueNotEqual(
|
||||||
|
"#id_type",
|
||||||
|
["game", "dlc"],
|
||||||
|
["#id_date_finished"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||||
|
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||||
|
getEl("#id_type").onchange = () => {
|
||||||
|
setupElementHandlers();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.addEventListener("htmx:beforeRequest", function (event) {
|
||||||
|
// Assuming 'Purchase1' is the element that triggers the HTMX request
|
||||||
|
if (event.target.id === "id_edition") {
|
||||||
|
var idEditionValue = document.getElementById("id_edition").value;
|
||||||
|
|
||||||
|
// Condition to check - replace this with your actual logic
|
||||||
|
if (idEditionValue != "") {
|
||||||
|
event.preventDefault(); // This cancels the HTMX request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { toISOUTCString } from "./utils.js";
|
||||||
|
|
||||||
|
for (let button of document.querySelectorAll("[data-target]")) {
|
||||||
|
let target = button.getAttribute("data-target");
|
||||||
|
let type = button.getAttribute("data-type");
|
||||||
|
let targetElement = document.querySelector(`#id_${target}`);
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (type == "now") {
|
||||||
|
targetElement.value = toISOUTCString(new Date());
|
||||||
|
} else if (type == "copy") {
|
||||||
|
const oppositeName =
|
||||||
|
targetElement.name == "timestamp_start"
|
||||||
|
? "timestamp_end"
|
||||||
|
: "timestamp_start";
|
||||||
|
document.querySelector(`[name='${oppositeName}']`).value =
|
||||||
|
targetElement.value;
|
||||||
|
} else if (type == "toggle") {
|
||||||
|
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
||||||
|
else targetElement.type = "datetime-local";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||||
|
* @param {Date} date
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function toISOUTCString(date) {
|
||||||
|
function stringAndPad(number) {
|
||||||
|
return number.toString().padStart(2, 0);
|
||||||
|
}
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = stringAndPad(date.getMonth() + 1);
|
||||||
|
const day = stringAndPad(date.getDate());
|
||||||
|
const hours = stringAndPad(date.getHours());
|
||||||
|
const minutes = stringAndPad(date.getMinutes());
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Sync values between source and target elements based on syncData configuration.
|
||||||
|
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
|
||||||
|
*/
|
||||||
|
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
|
||||||
|
const parentElement =
|
||||||
|
parentSelector === document
|
||||||
|
? document
|
||||||
|
: document.querySelector(parentSelector);
|
||||||
|
|
||||||
|
if (!parentElement) {
|
||||||
|
console.error(`The parent selector "${parentSelector}" is not valid.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Set up a single change event listener on the document for handling all source changes
|
||||||
|
parentElement.addEventListener("change", function (event) {
|
||||||
|
// Loop through each sync configuration item
|
||||||
|
syncData.forEach((syncItem) => {
|
||||||
|
// Check if the change event target matches the source selector
|
||||||
|
if (event.target.matches(syncItem.source)) {
|
||||||
|
const sourceElement = event.target;
|
||||||
|
const valueToSync = getValueFromProperty(
|
||||||
|
sourceElement,
|
||||||
|
syncItem.source_value
|
||||||
|
);
|
||||||
|
const targetElement = document.querySelector(syncItem.target);
|
||||||
|
|
||||||
|
if (targetElement && valueToSync !== null) {
|
||||||
|
targetElement[syncItem.target_value] = valueToSync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up a single focus event listener on the document for handling all target focuses
|
||||||
|
parentElement.addEventListener(
|
||||||
|
"focus",
|
||||||
|
function (event) {
|
||||||
|
// Loop through each sync configuration item
|
||||||
|
syncData.forEach((syncItem) => {
|
||||||
|
// Check if the focus event target matches the target selector
|
||||||
|
if (event.target.matches(syncItem.target)) {
|
||||||
|
// Remove the change event listener to stop syncing
|
||||||
|
// This assumes you want to stop syncing once any target receives focus
|
||||||
|
// You may need a more sophisticated way to remove listeners if you want to stop
|
||||||
|
// syncing selectively based on other conditions
|
||||||
|
document.removeEventListener("change", syncSelectInputUntilChanged);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
true
|
||||||
|
); // Use capture phase to ensure the event is captured during focus, not bubble
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Retrieve the value from the source element based on the provided property.
|
||||||
|
* @param {Element} sourceElement - The source HTML element.
|
||||||
|
* @param {string} property - The property to retrieve the value from.
|
||||||
|
*/
|
||||||
|
function getValueFromProperty(sourceElement, property) {
|
||||||
|
let source =
|
||||||
|
sourceElement instanceof HTMLSelectElement
|
||||||
|
? sourceElement.selectedOptions[0]
|
||||||
|
: sourceElement;
|
||||||
|
if (property.startsWith("dataset.")) {
|
||||||
|
let datasetKey = property.slice(8); // Remove 'dataset.' part
|
||||||
|
return source.dataset[datasetKey];
|
||||||
|
} else if (property in source) {
|
||||||
|
return source[property];
|
||||||
|
} else {
|
||||||
|
console.error(`Property ${property} is not valid for the option element.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Returns a single element by name.
|
||||||
|
* @param {string} selector The selector to look for.
|
||||||
|
*/
|
||||||
|
function getEl(selector) {
|
||||||
|
if (selector.startsWith("#")) {
|
||||||
|
return document.getElementById(selector.slice(1));
|
||||||
|
} else if (selector.startsWith(".")) {
|
||||||
|
return document.getElementsByClassName(selector);
|
||||||
|
} else {
|
||||||
|
return document.getElementsByTagName(selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Applies different behaviors to elements based on multiple conditional configurations.
|
||||||
|
* Each configuration is an array containing a condition function, an array of target element selectors,
|
||||||
|
* and two callback functions for handling matched and unmatched conditions.
|
||||||
|
* @param {...Array} configs Each configuration is an array of the form:
|
||||||
|
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
|
||||||
|
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
|
||||||
|
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
|
||||||
|
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
|
||||||
|
*/
|
||||||
|
function conditionalElementHandler(...configs) {
|
||||||
|
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
|
||||||
|
if (condition()) {
|
||||||
|
targetElements.forEach((elementName) => {
|
||||||
|
let el = getEl(elementName);
|
||||||
|
if (el === null) {
|
||||||
|
console.error(`Element ${elementName} doesn't exist.`);
|
||||||
|
} else {
|
||||||
|
callbackfn1(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
targetElements.forEach((elementName) => {
|
||||||
|
let el = getEl(elementName);
|
||||||
|
if (el === null) {
|
||||||
|
console.error(`Element ${elementName} doesn't exist.`);
|
||||||
|
} else {
|
||||||
|
callbackfn2(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableElementsWhenValueNotEqual(
|
||||||
|
targetSelect,
|
||||||
|
targetValue,
|
||||||
|
elementList
|
||||||
|
) {
|
||||||
|
return conditionalElementHandler([
|
||||||
|
() => {
|
||||||
|
let target = getEl(targetSelect);
|
||||||
|
console.debug(
|
||||||
|
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
|
||||||
|
);
|
||||||
|
console.debug(`
|
||||||
|
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
|
||||||
|
if (targetValue instanceof Array) {
|
||||||
|
if (targetValue.every((value) => target.value != value)) {
|
||||||
|
console.debug(
|
||||||
|
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
|
||||||
|
);
|
||||||
|
return target.value != targetValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elementList,
|
||||||
|
(el) => {
|
||||||
|
console.debug(
|
||||||
|
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
|
||||||
|
);
|
||||||
|
el.disabled = "disabled";
|
||||||
|
},
|
||||||
|
(el) => {
|
||||||
|
console.debug(
|
||||||
|
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
|
||||||
|
);
|
||||||
|
el.disabled = "";
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||||
|
return conditionalElementHandler([
|
||||||
|
() => {
|
||||||
|
return getEl(targetSelect).value == targetValue;
|
||||||
|
},
|
||||||
|
elementList,
|
||||||
|
(el) => {
|
||||||
|
el.disabled = "disabled";
|
||||||
|
},
|
||||||
|
(el) => {
|
||||||
|
el.disabled = "";
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
toISOUTCString,
|
||||||
|
syncSelectInputUntilChanged,
|
||||||
|
getEl,
|
||||||
|
conditionalElementHandler,
|
||||||
|
disableElementsWhenValueNotEqual,
|
||||||
|
disableElementsWhenTrue,
|
||||||
|
getValueFromProperty,
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
function elt(type, props, ...children) {
|
||||||
|
let dom = document.createElement(type);
|
||||||
|
if (props) Object.assign(dom, props);
|
||||||
|
for (let child of children) {
|
||||||
|
if (typeof child != "string") dom.appendChild(child);
|
||||||
|
else dom.appendChild(document.createTextNode(child));
|
||||||
|
}
|
||||||
|
return dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Node} targetNode
|
||||||
|
*/
|
||||||
|
function addToggleButton(targetNode) {
|
||||||
|
let manualToggleButton = elt(
|
||||||
|
"td",
|
||||||
|
{},
|
||||||
|
elt(
|
||||||
|
"div",
|
||||||
|
{ className: "basic-button" },
|
||||||
|
elt(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onclick: (event) => {
|
||||||
|
let textInputField = elt("input", { type: "text", id: targetNode.id });
|
||||||
|
targetNode.replaceWith(textInputField);
|
||||||
|
event.target.addEventListener("click", (event) => {
|
||||||
|
textInputField.replaceWith(targetNode);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Toggle manual"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
targetNode.parentElement.appendChild(manualToggleButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
|
||||||
|
|
||||||
|
toggleableFields.map((selector) => {
|
||||||
|
addToggleButton(document.querySelector(selector));
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
{% block title %}{{ title }}{% endblock title %}
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<table class="mx-auto">
|
<table class="mx-auto">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ form.as_table }}
|
||||||
{{ form.as_table }}
|
<tr>
|
||||||
<tr>
|
<td></td>
|
||||||
<td><input type="submit" value="Submit"/></td>
|
<td>
|
||||||
</tr>
|
<input type="submit" value="Submit" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% if script_name %}
|
||||||
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock scripts %}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<table class="mx-auto">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_table }}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit" name="submit" value="Submit" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit"
|
||||||
|
name="submit_and_redirect"
|
||||||
|
value="Submit & Create Purchase" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% if script_name %}
|
||||||
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock scripts %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<table class="mx-auto">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_table }}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit" name="submit" value="Submit" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit"
|
||||||
|
name="submit_and_redirect"
|
||||||
|
value="Submit & Create Edition" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% if script_name %}
|
||||||
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock scripts %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<table class="mx-auto">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_table }}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit" name="submit" value="Submit" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit"
|
||||||
|
name="submit_and_redirect"
|
||||||
|
value="Submit & Create Session" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if purchase_id %}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'delete_purchase' purchase_id %}" class="text-red-600" onclick="return confirm('Are you sure you want to delete this purchase?');">Delete</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% if script_name %}
|
||||||
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock scripts %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<table class="mx-auto">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in form %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ field.label_tag }}</th>
|
||||||
|
{% if field.name == "note" %}
|
||||||
|
<td>{{ field }}</td>
|
||||||
|
{% else %}
|
||||||
|
<td>{{ field }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
||||||
|
<td>
|
||||||
|
<div class="basic-button-container" hx-boost="false">
|
||||||
|
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
|
||||||
|
<button class="basic-button"
|
||||||
|
data-target="{{ field.name }}"
|
||||||
|
data-type="toggle">Toggle text</button>
|
||||||
|
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit" value="Submit" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% load static %}
|
||||||
|
<script type="module" src="{% static 'js/add_session.js' %}"></script>
|
||||||
|
{% endblock content %}
|
||||||
+111
-40
@@ -1,52 +1,123 @@
|
|||||||
<!doctype html>
|
{% load django_htmx %}
|
||||||
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8" />
|
||||||
<meta name="description" content="Self-hosted time-tracker."/>
|
<meta name="description" content="Self-hosted time-tracker." />
|
||||||
<meta name="keywords" content="time, tracking, video games, self-hosted"/>
|
<meta name="keywords" content="time, tracking, video games, self-hosted" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
|
<title>Timetracker -
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
{% block title %}
|
||||||
|
Untitled
|
||||||
|
{% endblock title %}
|
||||||
|
</title>
|
||||||
|
<script src="{% static 'js/htmx.min.js' %}"></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>
|
||||||
</head>
|
</head>
|
||||||
|
<body class="dark" hx-indicator="#indicator">
|
||||||
<body class="dark">
|
<img id="indicator"
|
||||||
<div class="dark:bg-gray-800 min-h-screen">
|
src="{% static 'icons/loading.png' %}"
|
||||||
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
alt="loading indicator" />
|
||||||
|
<div class="flex flex-col min-h-screen">
|
||||||
|
<nav class="dark:bg-gray-900 border-gray-200 h-24 flex items-center">
|
||||||
<div class="container flex flex-wrap items-center justify-between mx-auto">
|
<div class="container flex flex-wrap items-center justify-between mx-auto">
|
||||||
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
|
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
|
||||||
<span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
|
<span class="text-4xl">
|
||||||
|
<img src="{% static 'icons/schedule.png' %}"
|
||||||
|
height="48"
|
||||||
|
width="48"
|
||||||
|
alt="Timetracker Logo"
|
||||||
|
class="mr-4" />
|
||||||
|
</span>
|
||||||
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
|
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="w-full md:block md:w-auto">
|
<div class="w-full md:block md:w-auto">
|
||||||
<ul
|
<ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
|
||||||
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
|
<li class="relative group">
|
||||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
|
{% if user.is_authenticated %}
|
||||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
|
<a class="block py-2 pl-3 pr-4 hover:underline"
|
||||||
{% if game_available and platform_available %}
|
href="{% url 'add_game' %}">New</a>
|
||||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
|
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
|
||||||
{% endif %}
|
{% if purchase_available %}
|
||||||
{% if edition_available %}
|
<li>
|
||||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
{% endif %}
|
href="{% url 'add_device' %}">Device</a>
|
||||||
{% if purchase_available %}
|
</li>
|
||||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
|
{% endif %}
|
||||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
|
<li>
|
||||||
{% endif %}
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
{% if session_count > 0 %}
|
href="{% url 'add_game' %}">Game</a>
|
||||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
|
</li>
|
||||||
{% endif %}
|
{% if game_available and platform_available %}
|
||||||
</ul>
|
<li>
|
||||||
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
|
href="{% url 'add_edition' %}">Edition</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
|
href="{% url 'add_platform' %}">Platform</a>
|
||||||
|
</li>
|
||||||
|
{% if edition_available %}
|
||||||
|
<li>
|
||||||
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
|
href="{% url 'add_purchase' %}">Purchase</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if purchase_available %}
|
||||||
|
<li>
|
||||||
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
|
href="{% url 'add_session' %}">Session</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% if session_count > 0 %}
|
||||||
|
<li class="relative group">
|
||||||
|
<a class="block py-2 pl-3 pr-4 hover:underline"
|
||||||
|
href="{% url 'stats_by_year' 0 %}">Stats</a>
|
||||||
|
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
|
||||||
|
<li>
|
||||||
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
|
href="{% url 'stats_by_year' 0 %}">Overall</a>
|
||||||
|
</li>
|
||||||
|
{% for year in stats_dropdown_year_range %}
|
||||||
|
<li>
|
||||||
|
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
||||||
|
href="{% url 'stats_by_year' year %}">{{ year }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="block py-2 pl-3 pr-4 hover:underline"
|
||||||
|
href="{% url 'list_sessions' %}">All Sessions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="block py-2 pl-3 pr-4 hover:underline"
|
||||||
|
href="{% url 'logout' %}">Log Out</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="flex flex-1 dark:bg-gray-800 justify-center pt-8 pb-16">
|
||||||
|
{% block content %}
|
||||||
|
No content here.
|
||||||
|
{% endblock content %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
{% load version %}
|
||||||
{% block content %}No content here.{% endblock content %}
|
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
||||||
</div>
|
</div>
|
||||||
{% load version %}
|
{% block scripts %}
|
||||||
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
{% endblock scripts %}
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
components:
|
||||||
|
gamelink: "components/game_link.html"
|
||||||
|
popover: "components/popover.html"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% comment %}
|
||||||
|
title
|
||||||
|
text
|
||||||
|
{% endcomment %}
|
||||||
|
<a href="{{ link }}"
|
||||||
|
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">
|
||||||
|
{% 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" />
|
||||||
|
</svg>
|
||||||
|
{% endcomment %}
|
||||||
|
{{ text }}
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% comment %}
|
||||||
|
title
|
||||||
|
text
|
||||||
|
{% endcomment %}
|
||||||
|
<button type="button"
|
||||||
|
title="{{ title }}"
|
||||||
|
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">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
{{ text }}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<a href="{{ edit_url }}">
|
||||||
|
<button type="button"
|
||||||
|
title="Edit"
|
||||||
|
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="w-5 h-5">
|
||||||
|
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
|
||||||
|
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<span class="truncate-container">
|
||||||
|
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game_id %}">
|
||||||
|
{% if children %}
|
||||||
|
{{ children }}
|
||||||
|
{% else %}
|
||||||
|
{{ name }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- needs data-popover-target on triggering block -->
|
||||||
|
<!-- id -->
|
||||||
|
<!-- children -->
|
||||||
|
<div data-popover id="{{ id }}" 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">
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
{{ children }}
|
||||||
|
</div>
|
||||||
|
<div data-popper-arrow></div>
|
||||||
|
</div>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
{% block title %}{{ title }}{% endblock title %}
|
{{ 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-screen-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 }}.
|
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 %}
|
||||||
There are no games in the database. Start by clicking "New Game" and "New Platform".
|
There are no games in the database. Start by clicking "New Game" and "New Platform".
|
||||||
{% elif not purchase_available %}
|
{% elif not purchase_available %}
|
||||||
|
|||||||
@@ -1,125 +1,73 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% block title %}
|
||||||
{% block title %}{{ title }}{% endblock title %}
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="text-center text-xl mb-4 dark:text-slate-400">
|
<div class="flex-col">
|
||||||
{% if dataset.count >= 2 %}
|
{% if dataset_count >= 1 %}
|
||||||
<img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" />
|
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
|
||||||
{% endif %}
|
<div class="mx-auto text-center my-4">
|
||||||
{% if dataset.count >= 1 %}
|
<a id="last-session-start"
|
||||||
<div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
|
href="{{ start_session_url }}"
|
||||||
{% endif %}
|
hx-get="{{ start_session_url }}"
|
||||||
{% if purchase or platform or edition or game or ownership_type %}
|
hx-swap="afterbegin"
|
||||||
<span class="block">
|
hx-target=".responsive-table tbody"
|
||||||
<a class="text-red-400 inline" href="{% url 'list_sessions' %}">
|
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline">
|
class="{% if last.timestamp_end == null %}invisible{% endif %}">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{% if purchase %}
|
|
||||||
Filtering by purchase "{{ purchase }}"
|
|
||||||
(<a href="{% url 'edit_purchase' purchase.id %}" class="hover:underline dark:text-white">Edit</a>)
|
|
||||||
{% elif platform %}
|
|
||||||
Filtering by purchase "{{ platform }}"
|
|
||||||
(<a href="{% url 'edit_platform' platform.id %}" class="hover:underline dark:text-white">Edit</a>)
|
|
||||||
{% elif game %}
|
|
||||||
Filtering by purchase "{{ game }}"
|
|
||||||
(<a href="{% url 'edit_game' game.id %}" class="hover:underline dark:text-white">Edit</a>)
|
|
||||||
{% elif edition %}
|
|
||||||
Filtering by purchase "{{ edition }}"
|
|
||||||
(<a href="{% url 'edit_edition' edition.id %}" class="hover:underline dark:text-white">Edit</a>)
|
|
||||||
{% elif ownership_type %}
|
|
||||||
Filtering by ownership type "{{ ownership_type }}"
|
|
||||||
{% endif%}
|
|
||||||
</span>
|
|
||||||
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_edition' purchase.edition.id %}">See all platforms</a>{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if dataset.count >= 1 %}
|
|
||||||
<a href="{% url 'start_session' last.id %}">
|
|
||||||
<button type="button" title="Track last tracked" class="mt-10 py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
|
||||||
</svg>
|
|
||||||
{{ last.purchase }}
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-2xl mx-auto dark:bg-slate-700 p-2 justify-center">
|
|
||||||
<div class="dark:border-white dark:text-slate-300 text-lg">Purchase</div>
|
|
||||||
<div class="dark:border-white dark:text-slate-300 text-lg">Platform</div>
|
|
||||||
<div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>
|
|
||||||
<div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div>
|
|
||||||
<div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
|
|
||||||
<div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
|
|
||||||
{% for data in dataset %}
|
|
||||||
<div class="purchase-name">
|
|
||||||
<span class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">{{ data.purchase.edition }} <span class="dark:text-slate-400">(<a class="hover:underline" href="{% url 'list_sessions_by_ownership_type' data.purchase.ownership_type %}">{{ data.purchase.get_ownership_type_display }}</a>)</span></span>
|
|
||||||
|
|
||||||
<span>
|
|
||||||
<a href="{% url 'list_sessions_by_game' data.purchase.edition.game.id %}">
|
|
||||||
<img src="{% static 'icons/game_white.png' %}" width="32" class="inline" alt="Filter by this game" title="Filter by this game" />
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'list_sessions_by_edition' data.purchase.edition.id %}">
|
|
||||||
<img src="{% static 'icons/edition_white.png' %}" width="32" class="inline" alt="Filter by this edition" title="Filter by this edition" />
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'list_sessions_by_purchase' data.purchase.id %}">
|
|
||||||
<img src="{% static 'icons/purchase_white.png' %}" width="32" class="inline" alt="Filter by this purchase" title="Filter by this purchase" />
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">
|
|
||||||
{% if data.purchase.platform != data.purchase.edition.platform %}
|
|
||||||
{{data.purchase.edition.platform}} on {{ data.purchase.platform }}
|
|
||||||
{% else %}
|
|
||||||
{{ data.purchase.platform }}
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
|
{% endif %}
|
||||||
<div class="dark:text-slate-400 text-center">
|
{% if dataset_count != 0 %}
|
||||||
{% if data.unfinished %}
|
<table class="responsive-table">
|
||||||
<span class="text-red-400">Not finished yet.</span>
|
<thead>
|
||||||
{% elif data.duration_manual %}
|
<tr>
|
||||||
--
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
|
||||||
{% else %}
|
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
|
||||||
{{ data.timestamp_end | date:"d/m/Y H:i" }}
|
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
|
||||||
{% endif %}
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
|
||||||
</div>
|
</tr>
|
||||||
<div class="dark:text-slate-400 flex">{{ data.duration_formatted }}{% if data.duration_manual %} <svg title="Added manually" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 ml-1 self-center">
|
</thead>
|
||||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
|
<tbody>
|
||||||
</svg>
|
{% for session in dataset %}
|
||||||
{% endif %}</div>
|
{% partialdef session-row inline=True %}
|
||||||
<div id="button-container" class="flex justify-end">
|
<tr>
|
||||||
{% if data.unfinished %}
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
|
||||||
<a href="{% url 'update_session' data.id %}">
|
<span class="inline-block relative">
|
||||||
<button type="button" title="Set to finished" class="py-1 px-2 flex justify-center items-center 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 w-7 h-4 rounded-lg ">
|
<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" href="{% url 'view_game' session.purchase.edition.game.id %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
{{ session.purchase.edition.name }}
|
||||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
|
</a>
|
||||||
</svg>
|
</span>
|
||||||
</button>
|
</td>
|
||||||
</a>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
|
||||||
{% endif %}
|
{{ session.timestamp_start | date:"d/m/Y H:i" }}
|
||||||
<a href="{% url 'edit_session' data.id %}">
|
</td>
|
||||||
<button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
{% if not session.timestamp_end %}
|
||||||
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
|
{% url 'list_sessions_end_session' session.id as end_session_url %}
|
||||||
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
|
<a href="{{ end_session_url }}"
|
||||||
</svg>
|
hx-get="{{ end_session_url }}"
|
||||||
</button>
|
hx-target="closest tr"
|
||||||
</a>
|
hx-swap="outerHTML"
|
||||||
<a href="{% url 'delete_session' data.id %}">
|
hx-indicator="#indicator"
|
||||||
<button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center bg-red-600 hover:bg-red-700 focus:ring-red-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
|
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
<span class="text-yellow-300">Finish now?</span>
|
||||||
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" />
|
</a>
|
||||||
</svg>
|
{% elif session.duration_manual %}
|
||||||
</button>
|
--
|
||||||
</a>
|
{% else %}
|
||||||
</div>
|
{{ session.timestamp_end | date:"d/m/Y H:i" }}
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endpartialdef %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ form.related_purchase }}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Login
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center flex-col">
|
||||||
|
<h2 class="text-3xl text-white mb-8">Please log in to continue</h2>
|
||||||
|
<form method="post">
|
||||||
|
<table>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_table }}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit" value="Login" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</form>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% partialdef purchase-name %}
|
||||||
|
{% if purchase.type != 'game' %}
|
||||||
|
{% #gamelink game_id=purchase.edition.game.id %}
|
||||||
|
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
|
||||||
|
{% /gamelink %}
|
||||||
|
{% else %}
|
||||||
|
{% gamelink game_id=purchase.edition.game.id name=purchase.edition.name %}
|
||||||
|
{% endif %}
|
||||||
|
{% endpartialdef %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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">
|
||||||
|
<form method="get" class="text-center">
|
||||||
|
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
|
||||||
|
<select name="year"
|
||||||
|
id="yearSelect"
|
||||||
|
onchange="this.form.submit();"
|
||||||
|
class="mx-2">
|
||||||
|
{% for year_item in stats_dropdown_year_range %}
|
||||||
|
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-5xl text-center my-6">Playtime</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
|
||||||
|
</tr>
|
||||||
|
{% if total_games %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if all_finished_this_year_count %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ longest_session_time }} ({% gamelink game_id=longest_session_game.id name=longest_session_game.name %})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ highest_session_count }} ({% gamelink game_id=highest_session_count_game.id name=highest_session_count_game.name %})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{{ highest_session_average }} ({% gamelink game_id=highest_session_average_game.id name=highest_session_average_game.name %})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% gamelink game_id=first_play_game.id name=first_play_game.name %} ({{ first_play_date }})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% gamelink game_id=last_play_game.id name=last_play_game.name %} ({{ last_play_date }})</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if month_playtime %}
|
||||||
|
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<tbody>
|
||||||
|
{% for month in month_playtimes %}
|
||||||
|
<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 font-mono">{{ month.playtime }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h1 class="text-5xl text-center my-6">Purchases</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{{ dropped_count }} ({{ dropped_percentage }}%)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<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">Playtime (hours)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for game in top_10_games_by_playtime %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{% gamelink game_id=game.id name=game.name %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<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">Playtime (hours)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in total_playtime_per_platform %}
|
||||||
|
<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.formatted_playtime }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if all_finished_this_year %}
|
||||||
|
<h1 class="text-5xl text-center my-6">Finished</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for purchase in all_finished_this_year %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{% partial purchase-name %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if this_year_finished_this_year %}
|
||||||
|
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for purchase in this_year_finished_this_year %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{% partial purchase-name %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if purchased_this_year_finished_this_year %}
|
||||||
|
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for purchase in purchased_this_year_finished_this_year %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{% partial purchase-name %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if purchased_unfinished %}
|
||||||
|
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for purchase in purchased_unfinished %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{% partial purchase-name %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if all_purchased_this_year %}
|
||||||
|
<h1 class="text-5xl text-center my-6">All Purchases</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for purchase in all_purchased_this_year %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
{% partial purchase-name %}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
|
{% load static %}
|
||||||
|
{% load markdown_extras %}
|
||||||
|
{% block content %}
|
||||||
|
<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 class="flex gap-5 mb-3">
|
||||||
|
<span class="text-wrap max-w-80 text-4xl">
|
||||||
|
<span class="font-bold font-serif">{{ game.name }}</span> <span data-popover-target="popover-year" class="text-slate-500 text-2xl">{{ game.year_released }}</span>
|
||||||
|
{% #popover id="popover-year" %}
|
||||||
|
Original release year
|
||||||
|
{% /popover %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 dark:text-slate-400 mb-3">
|
||||||
|
<span data-popover-target="popover-hours" class="flex gap-2 items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" 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" />
|
||||||
|
</svg>
|
||||||
|
{{ hours_sum }}
|
||||||
|
{% #popover id="popover-hours" %}
|
||||||
|
Total hours played
|
||||||
|
{% /popover %}
|
||||||
|
</span>
|
||||||
|
<span data-popover-target="popover-sessions" class="flex gap-2 items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
|
||||||
|
</svg>
|
||||||
|
{{ session_count }}
|
||||||
|
{% #popover id="popover-sessions" %}
|
||||||
|
Number of sessions
|
||||||
|
{% /popover %}
|
||||||
|
</span>
|
||||||
|
<span data-popover-target="popover-average" class="flex gap-2 items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
|
||||||
|
</svg>
|
||||||
|
{{ session_average_without_manual }}
|
||||||
|
{% #popover id="popover-average" %}
|
||||||
|
Average playtime per session
|
||||||
|
{% /popover %}
|
||||||
|
</span>
|
||||||
|
<span data-popover-target="popover-playrange" class="flex gap-2 items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
|
||||||
|
</svg>
|
||||||
|
{{ playrange }}
|
||||||
|
{% #popover id="popover-playrange" %}
|
||||||
|
Earliest and latest dates played
|
||||||
|
{% /popover %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
|
||||||
|
<a href="{% url 'edit_game' game.id %}">
|
||||||
|
<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">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'delete_game' game.id %}">
|
||||||
|
<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">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl mt-4 mb-1 font-condensed">
|
||||||
|
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
|
||||||
|
</h1>
|
||||||
|
<ul>
|
||||||
|
{% for edition in editions %}
|
||||||
|
<li class="sm:pl-2 flex items-center">
|
||||||
|
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
|
||||||
|
{% if edition.wikidata %}
|
||||||
|
<span class="hidden sm:inline">
|
||||||
|
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
|
||||||
|
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% url 'edit_edition' edition.id as edit_url %}
|
||||||
|
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||||
|
</li>
|
||||||
|
<ul>
|
||||||
|
{% for purchase in edition.game_purchases %}
|
||||||
|
<li class="sm:pl-6 flex items-center {% if purchase.date_refunded %}text-red-600{% endif %}">
|
||||||
|
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}
|
||||||
|
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
|
||||||
|
{% url 'edit_purchase' purchase.id as edit_url %}
|
||||||
|
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||||
|
</li>
|
||||||
|
<ul>
|
||||||
|
{% for related_purchase in purchase.nongame_related_purchases %}
|
||||||
|
<li class="sm:pl-12 flex items-center">
|
||||||
|
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }})
|
||||||
|
{% url 'edit_purchase' related_purchase.id as edit_url %}
|
||||||
|
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center font-condensed">
|
||||||
|
Sessions
|
||||||
|
<span class="dark:text-slate-500" id="session-count">({{ session_count }})</span>
|
||||||
|
{% if latest_session_id %}
|
||||||
|
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %}
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
title="Start new session"
|
||||||
|
href="{{ add_session_link }}"
|
||||||
|
hx-get="{{ add_session_link }}"
|
||||||
|
hx-vals="js:{session_count:getSessionCount()}"
|
||||||
|
hx-target="#session-list"
|
||||||
|
hx-swap="afterbegin"
|
||||||
|
>New</a>
|
||||||
|
{% endif %}
|
||||||
|
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
|
||||||
|
</h1>
|
||||||
|
<ul id="session-list">
|
||||||
|
{% for session in sessions %}
|
||||||
|
{% partialdef session-info inline=True %}
|
||||||
|
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1">
|
||||||
|
{{ session.timestamp_start | date:"d/m/Y H:i" }}{% if session.timestamp_end %}-{{ session.timestamp_end | date:"H:i" }}{% endif %}
|
||||||
|
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
|
||||||
|
{% url 'edit_session' session.id as edit_url %}
|
||||||
|
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||||
|
{% if not session.timestamp_end %}
|
||||||
|
{% url 'view_game_end_session' session.id as end_session_url %}
|
||||||
|
<a
|
||||||
|
class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center"
|
||||||
|
href="{{ end_session_url }}"
|
||||||
|
hx-get="{{ end_session_url }}"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-vals="js:{session_count:getSessionCount()}"
|
||||||
|
hx-indicator="#indicator"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" class="h-3" x="0px" y="0px" viewBox="0 0 24 24">
|
||||||
|
<path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="sm:pl-4 markdown-content">{{ session.note|markdown }}</li>
|
||||||
|
<div class="hidden" hx-swap-oob="innerHTML:#session-count">
|
||||||
|
({{ session_count }})
|
||||||
|
</div>
|
||||||
|
{% endpartialdef %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function getSessionCount() {
|
||||||
|
return document.getElementById('session-count').textContent.match("[0-9]+");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock content %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="markdown")
|
||||||
|
def markdown_format(text):
|
||||||
|
return mark_safe(markdown.markdown(text))
|
||||||
+80
-33
@@ -4,67 +4,114 @@ from games import views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="index"),
|
path("", views.index, name="index"),
|
||||||
|
path("device/add", views.add_device, name="add_device"),
|
||||||
|
path("edition/add", views.add_edition, name="add_edition"),
|
||||||
path(
|
path(
|
||||||
"list-sessions/recent",
|
"edition/add/for-game/<int:game_id>",
|
||||||
|
views.add_edition,
|
||||||
|
name="add_edition_for_game",
|
||||||
|
),
|
||||||
|
path("edition/<int:edition_id>/edit", views.edit_edition, name="edit_edition"),
|
||||||
|
path("game/add", views.add_game, name="add_game"),
|
||||||
|
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
|
||||||
|
path("game/<int:game_id>/view", views.view_game, name="view_game"),
|
||||||
|
path("game/<int:game_id>/delete", views.delete_game, name="delete_game"),
|
||||||
|
path("platform/add", views.add_platform, name="add_platform"),
|
||||||
|
path("platform/<int:platform_id>/edit", views.edit_platform, name="edit_platform"),
|
||||||
|
path("purchase/add", views.add_purchase, name="add_purchase"),
|
||||||
|
path("purchase/<int:purchase_id>/edit", views.edit_purchase, name="edit_purchase"),
|
||||||
|
path(
|
||||||
|
"purchase/<int:purchase_id>/delete",
|
||||||
|
views.delete_purchase,
|
||||||
|
name="delete_purchase",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"purchase/related-purchase-by-edition",
|
||||||
|
views.related_purchase_by_edition,
|
||||||
|
name="related_purchase_by_edition",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"purchase/add/for-edition/<int:edition_id>",
|
||||||
|
views.add_purchase,
|
||||||
|
name="add_purchase_for_edition",
|
||||||
|
),
|
||||||
|
path("session/add", views.add_session, name="add_session"),
|
||||||
|
path(
|
||||||
|
"session/add/for-purchase/<int:purchase_id>",
|
||||||
|
views.add_session,
|
||||||
|
name="add_session_for_purchase",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"session/add/from-game/<int:session_id>",
|
||||||
|
views.new_session_from_existing_session,
|
||||||
|
{"template": "view_game.html#session-info"},
|
||||||
|
name="view_game_start_session_from_session",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"session/add/from-list/<int:session_id>",
|
||||||
|
views.new_session_from_existing_session,
|
||||||
|
{"template": "list_sessions.html#session-row"},
|
||||||
|
name="list_sessions_start_session_from_session",
|
||||||
|
),
|
||||||
|
path("session/<int:session_id>/edit", views.edit_session, name="edit_session"),
|
||||||
|
path(
|
||||||
|
"session/<int:session_id>/delete",
|
||||||
|
views.delete_session,
|
||||||
|
name="delete_session",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"session/end/from-game/<int:session_id>",
|
||||||
|
views.end_session,
|
||||||
|
{"template": "view_game.html#session-info"},
|
||||||
|
name="view_game_end_session",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"session/end/from-list/<int:session_id>",
|
||||||
|
views.end_session,
|
||||||
|
{"template": "list_sessions.html#session-row"},
|
||||||
|
name="list_sessions_end_session",
|
||||||
|
),
|
||||||
|
path("session/list", views.list_sessions, name="list_sessions"),
|
||||||
|
path(
|
||||||
|
"session/list/recent",
|
||||||
views.list_sessions,
|
views.list_sessions,
|
||||||
{"filter": "recent"},
|
{"filter": "recent"},
|
||||||
name="list_sessions_recent",
|
name="list_sessions_recent",
|
||||||
),
|
),
|
||||||
path("add-game/", views.add_game, name="add_game"),
|
|
||||||
path("add-platform/", views.add_platform, name="add_platform"),
|
|
||||||
path("add-session/", views.add_session, name="add_session"),
|
|
||||||
path(
|
path(
|
||||||
"update-session/by-session/<int:session_id>",
|
"session/list/by-purchase/<int:purchase_id>",
|
||||||
views.update_session,
|
|
||||||
name="update_session",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"start-session/<int:last_session_id>",
|
|
||||||
views.start_session,
|
|
||||||
name="start_session",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"delete_session/by-id/<int:session_id>",
|
|
||||||
views.delete_session,
|
|
||||||
name="delete_session",
|
|
||||||
),
|
|
||||||
path("add-purchase/", views.add_purchase, name="add_purchase"),
|
|
||||||
path("add-edition/", views.add_edition, name="add_edition"),
|
|
||||||
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
|
|
||||||
path("edit-game/<int:game_id>", views.edit_game, name="edit_game"),
|
|
||||||
path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
|
|
||||||
path("add-device/", views.add_device, name="add_device"),
|
|
||||||
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
|
|
||||||
path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
|
|
||||||
path("list-sessions/", views.list_sessions, name="list_sessions"),
|
|
||||||
path(
|
|
||||||
"list-sessions/by-purchase/<int:purchase_id>",
|
|
||||||
views.list_sessions,
|
views.list_sessions,
|
||||||
{"filter": "purchase"},
|
{"filter": "purchase"},
|
||||||
name="list_sessions_by_purchase",
|
name="list_sessions_by_purchase",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"list-sessions/by-platform/<int:platform_id>",
|
"session/list/by-platform/<int:platform_id>",
|
||||||
views.list_sessions,
|
views.list_sessions,
|
||||||
{"filter": "platform"},
|
{"filter": "platform"},
|
||||||
name="list_sessions_by_platform",
|
name="list_sessions_by_platform",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"list-sessions/by-game/<int:game_id>",
|
"session/list/by-game/<int:game_id>",
|
||||||
views.list_sessions,
|
views.list_sessions,
|
||||||
{"filter": "game"},
|
{"filter": "game"},
|
||||||
name="list_sessions_by_game",
|
name="list_sessions_by_game",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"list-sessions/by-edition/<int:edition_id>",
|
"session/list/by-edition/<int:edition_id>",
|
||||||
views.list_sessions,
|
views.list_sessions,
|
||||||
{"filter": "edition"},
|
{"filter": "edition"},
|
||||||
name="list_sessions_by_edition",
|
name="list_sessions_by_edition",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"list-sessions/by-ownership/<str:ownership_type>",
|
"session/list/by-ownership/<str:ownership_type>",
|
||||||
views.list_sessions,
|
views.list_sessions,
|
||||||
{"filter": "ownership_type"},
|
{"filter": "ownership_type"},
|
||||||
name="list_sessions_by_ownership_type",
|
name="list_sessions_by_ownership_type",
|
||||||
),
|
),
|
||||||
|
path("stats/", views.stats_alltime, name="stats_alltime"),
|
||||||
|
path(
|
||||||
|
"stats/<int:year>",
|
||||||
|
views.stats,
|
||||||
|
name="stats_by_year",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
+786
-81
@@ -1,60 +1,111 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from typing import Any, Callable
|
||||||
|
|
||||||
from common.plots import playtime_over_time_chart
|
from django.contrib.auth.decorators import login_required
|
||||||
from common.time import now as now_with_tz
|
|
||||||
from django.conf import settings
|
from django.db.models import (
|
||||||
|
Avg,
|
||||||
|
Count,
|
||||||
|
ExpressionWrapper,
|
||||||
|
F,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
Sum,
|
||||||
|
fields,
|
||||||
|
IntegerField,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import TruncDate, ExtractMonth, TruncMonth
|
||||||
|
from django.http import (
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseBadRequest,
|
||||||
|
HttpResponseRedirect,
|
||||||
|
)
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from common.time import format_duration
|
||||||
|
from common.utils import safe_division, safe_getattr
|
||||||
|
|
||||||
from .forms import (
|
from .forms import (
|
||||||
|
DeviceForm,
|
||||||
|
EditionForm,
|
||||||
GameForm,
|
GameForm,
|
||||||
PlatformForm,
|
PlatformForm,
|
||||||
PurchaseForm,
|
PurchaseForm,
|
||||||
SessionForm,
|
SessionForm,
|
||||||
EditionForm,
|
|
||||||
DeviceForm,
|
|
||||||
)
|
)
|
||||||
from .models import Game, Platform, Purchase, Session, Edition
|
from .models import Edition, Game, Platform, Purchase, Session
|
||||||
|
|
||||||
|
|
||||||
def model_counts(request):
|
def model_counts(request):
|
||||||
return {
|
return {
|
||||||
"game_available": Game.objects.count() != 0,
|
"game_available": Game.objects.exists(),
|
||||||
"edition_available": Edition.objects.count() != 0,
|
"edition_available": Edition.objects.exists(),
|
||||||
"platform_available": Platform.objects.count() != 0,
|
"platform_available": Platform.objects.exists(),
|
||||||
"purchase_available": Purchase.objects.count() != 0,
|
"purchase_available": Purchase.objects.exists(),
|
||||||
"session_count": Session.objects.count(),
|
"session_count": Session.objects.exists(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def add_session(request):
|
def stats_dropdown_year_range(request):
|
||||||
|
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_session(request, purchase_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
initial = {}
|
initial = {"timestamp_start": timezone.now()}
|
||||||
|
|
||||||
now = now_with_tz()
|
last = Session.objects.last()
|
||||||
initial["timestamp_start"] = now
|
|
||||||
|
|
||||||
last = Session.objects.all().last()
|
|
||||||
if last != None:
|
if last != None:
|
||||||
initial["purchase"] = last.purchase
|
initial["purchase"] = last.purchase
|
||||||
|
|
||||||
form = SessionForm(request.POST or None, initial=initial)
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
form = SessionForm(request.POST or None, initial=initial)
|
||||||
form.save()
|
if form.is_valid():
|
||||||
return redirect("list_sessions")
|
form.save()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
else:
|
||||||
|
if purchase_id:
|
||||||
|
purchase = Purchase.objects.get(id=purchase_id)
|
||||||
|
form = SessionForm(
|
||||||
|
initial={
|
||||||
|
**initial,
|
||||||
|
"purchase": purchase,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SessionForm(initial=initial)
|
||||||
|
|
||||||
context["title"] = "Add New Session"
|
context["title"] = "Add New Session"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
return render(request, "add.html", context)
|
return render(request, "add_session.html", context)
|
||||||
|
|
||||||
|
|
||||||
def update_session(request, session_id=None):
|
def use_custom_redirect(
|
||||||
session = Session.objects.get(id=session_id)
|
func: Callable[..., HttpResponse]
|
||||||
session.finish_now()
|
) -> Callable[..., HttpResponse]:
|
||||||
session.save()
|
"""
|
||||||
return redirect("list_sessions")
|
Will redirect to "return_path" session variable if set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||||
|
response = func(request, *args, **kwargs)
|
||||||
|
if isinstance(response, HttpResponseRedirect) and (
|
||||||
|
next_url := request.session.get("return_path")
|
||||||
|
):
|
||||||
|
return HttpResponseRedirect(next_url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
def edit_session(request, session_id=None):
|
def edit_session(request, session_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
session = Session.objects.get(id=session_id)
|
session = Session.objects.get(id=session_id)
|
||||||
@@ -64,9 +115,11 @@ def edit_session(request, session_id=None):
|
|||||||
return redirect("list_sessions")
|
return redirect("list_sessions")
|
||||||
context["title"] = "Edit Session"
|
context["title"] = "Edit Session"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
return render(request, "add.html", context)
|
return render(request, "add_session.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
def edit_purchase(request, purchase_id=None):
|
def edit_purchase(request, purchase_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
purchase = Purchase.objects.get(id=purchase_id)
|
purchase = Purchase.objects.get(id=purchase_id)
|
||||||
@@ -76,9 +129,13 @@ def edit_purchase(request, purchase_id=None):
|
|||||||
return redirect("list_sessions")
|
return redirect("list_sessions")
|
||||||
context["title"] = "Edit Purchase"
|
context["title"] = "Edit Purchase"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
return render(request, "add.html", context)
|
context["purchase_id"] = purchase_id
|
||||||
|
context["script_name"] = "add_purchase.js"
|
||||||
|
return render(request, "add_purchase.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
def edit_game(request, game_id=None):
|
def edit_game(request, game_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
purchase = Game.objects.get(id=game_id)
|
purchase = Game.objects.get(id=game_id)
|
||||||
@@ -91,6 +148,88 @@ def edit_game(request, game_id=None):
|
|||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_game(request, game_id=None):
|
||||||
|
game = get_object_or_404(Game, id=game_id)
|
||||||
|
game.delete()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def view_game(request, game_id=None):
|
||||||
|
game = Game.objects.get(id=game_id)
|
||||||
|
nongame_related_purchases_prefetch = Prefetch(
|
||||||
|
"related_purchases",
|
||||||
|
queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by(
|
||||||
|
"date_purchased"
|
||||||
|
),
|
||||||
|
to_attr="nongame_related_purchases",
|
||||||
|
)
|
||||||
|
game_purchases_prefetch = Prefetch(
|
||||||
|
"purchase_set",
|
||||||
|
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
|
||||||
|
nongame_related_purchases_prefetch
|
||||||
|
),
|
||||||
|
to_attr="game_purchases",
|
||||||
|
)
|
||||||
|
editions = (
|
||||||
|
Edition.objects.filter(game=game)
|
||||||
|
.prefetch_related(game_purchases_prefetch)
|
||||||
|
.order_by("year_released")
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions = Session.objects.prefetch_related("device").filter(
|
||||||
|
purchase__edition__game=game
|
||||||
|
)
|
||||||
|
session_count = sessions.count()
|
||||||
|
session_count_without_manual = (
|
||||||
|
Session.objects.without_manual().filter(purchase__edition__game=game).count()
|
||||||
|
)
|
||||||
|
|
||||||
|
if sessions:
|
||||||
|
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
|
||||||
|
latest_session = sessions.latest()
|
||||||
|
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
|
||||||
|
|
||||||
|
playrange = (
|
||||||
|
playrange_start
|
||||||
|
if playrange_start == playrange_end
|
||||||
|
else f"{playrange_start} — {playrange_end}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
playrange = "N/A"
|
||||||
|
latest_session = None
|
||||||
|
|
||||||
|
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
|
||||||
|
total_hours_without_manual = float(
|
||||||
|
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"edition_count": editions.count(),
|
||||||
|
"editions": editions,
|
||||||
|
"game": game,
|
||||||
|
"playrange": playrange,
|
||||||
|
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
|
||||||
|
"session_average_without_manual": round(
|
||||||
|
safe_division(
|
||||||
|
total_hours_without_manual, int(session_count_without_manual)
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"session_count": session_count,
|
||||||
|
"sessions_with_notes_count": sessions.exclude(note="").count(),
|
||||||
|
"sessions": sessions.order_by("-timestamp_start"),
|
||||||
|
"title": f"Game Overview - {game.name}",
|
||||||
|
"hours_sum": total_hours,
|
||||||
|
"latest_session_id": safe_getattr(latest_session, "pk"),
|
||||||
|
}
|
||||||
|
|
||||||
|
request.session["return_path"] = request.path
|
||||||
|
return render(request, "view_game.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
def edit_platform(request, platform_id=None):
|
def edit_platform(request, platform_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
purchase = Platform.objects.get(id=platform_id)
|
purchase = Platform.objects.get(id=platform_id)
|
||||||
@@ -103,6 +242,8 @@ def edit_platform(request, platform_id=None):
|
|||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
def edit_edition(request, edition_id=None):
|
def edit_edition(request, edition_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
edition = Edition.objects.get(id=edition_id)
|
edition = Edition.objects.get(id=edition_id)
|
||||||
@@ -115,25 +256,64 @@ def edit_edition(request, edition_id=None):
|
|||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
def start_session(request, last_session_id: int):
|
def related_purchase_by_edition(request):
|
||||||
last_session = Session.objects.get(id=last_session_id)
|
edition_id = request.GET.get("edition")
|
||||||
session = SessionForm(
|
if not edition_id:
|
||||||
{
|
return HttpResponseBadRequest("Invalid edition_id")
|
||||||
"purchase": last_session.purchase.id,
|
form = PurchaseForm()
|
||||||
"timestamp_start": now_with_tz(),
|
form.fields["related_purchase"].queryset = Purchase.objects.filter(
|
||||||
"device": last_session.device,
|
edition_id=edition_id, type=Purchase.GAME
|
||||||
|
).order_by("edition__sort_name")
|
||||||
|
return render(request, "partials/related_purchase_field.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
def clone_session_by_id(session_id: int) -> Session:
|
||||||
|
session = get_object_or_404(Session, id=session_id)
|
||||||
|
clone = session
|
||||||
|
clone.pk = None
|
||||||
|
clone.timestamp_start = timezone.now()
|
||||||
|
clone.timestamp_end = None
|
||||||
|
clone.note = ""
|
||||||
|
clone.save()
|
||||||
|
return clone
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
|
def new_session_from_existing_session(request, session_id: int, template: str = ""):
|
||||||
|
session = clone_session_by_id(session_id)
|
||||||
|
if request.htmx:
|
||||||
|
context = {
|
||||||
|
"session": session,
|
||||||
|
"session_count": int(request.GET.get("session_count", 0)) + 1,
|
||||||
}
|
}
|
||||||
)
|
return render(request, template, context)
|
||||||
session.save()
|
|
||||||
return redirect("list_sessions")
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
|
def end_session(request, session_id: int, template: str = ""):
|
||||||
|
session = get_object_or_404(Session, id=session_id)
|
||||||
|
session.timestamp_end = timezone.now()
|
||||||
|
session.save()
|
||||||
|
if request.htmx:
|
||||||
|
context = {
|
||||||
|
"session": session,
|
||||||
|
"session_count": request.GET.get("session_count", 0),
|
||||||
|
}
|
||||||
|
return render(request, template, context)
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def delete_session(request, session_id=None):
|
def delete_session(request, session_id=None):
|
||||||
session = Session.objects.get(id=session_id)
|
session = get_object_or_404(Session, id=session_id)
|
||||||
session.delete()
|
session.delete()
|
||||||
return redirect("list_sessions")
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def list_sessions(
|
def list_sessions(
|
||||||
request,
|
request,
|
||||||
filter="",
|
filter="",
|
||||||
@@ -146,84 +326,607 @@ def list_sessions(
|
|||||||
context = {}
|
context = {}
|
||||||
context["title"] = "Sessions"
|
context["title"] = "Sessions"
|
||||||
|
|
||||||
|
all_sessions = Session.objects.prefetch_related(
|
||||||
|
"purchase", "purchase__edition", "purchase__edition__game"
|
||||||
|
).order_by("-timestamp_start")
|
||||||
|
|
||||||
if filter == "purchase":
|
if filter == "purchase":
|
||||||
dataset = Session.objects.filter(purchase=purchase_id)
|
dataset = all_sessions.filter(purchase=purchase_id)
|
||||||
context["purchase"] = Purchase.objects.get(id=purchase_id)
|
context["purchase"] = Purchase.objects.get(id=purchase_id)
|
||||||
elif filter == "platform":
|
elif filter == "platform":
|
||||||
dataset = Session.objects.filter(purchase__platform=platform_id)
|
dataset = all_sessions.filter(purchase__platform=platform_id)
|
||||||
context["platform"] = Platform.objects.get(id=platform_id)
|
context["platform"] = Platform.objects.get(id=platform_id)
|
||||||
elif filter == "edition":
|
elif filter == "edition":
|
||||||
dataset = Session.objects.filter(purchase__edition=edition_id)
|
dataset = all_sessions.filter(purchase__edition=edition_id)
|
||||||
context["edition"] = Edition.objects.get(id=edition_id)
|
context["edition"] = Edition.objects.get(id=edition_id)
|
||||||
elif filter == "game":
|
elif filter == "game":
|
||||||
dataset = Session.objects.filter(purchase__edition__game=game_id)
|
dataset = all_sessions.filter(purchase__edition__game=game_id)
|
||||||
context["game"] = Game.objects.get(id=game_id)
|
context["game"] = Game.objects.get(id=game_id)
|
||||||
elif filter == "ownership_type":
|
elif filter == "ownership_type":
|
||||||
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
|
dataset = all_sessions.filter(purchase__ownership_type=ownership_type)
|
||||||
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
|
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
|
||||||
elif filter == "recent":
|
elif filter == "recent":
|
||||||
dataset = Session.objects.filter(
|
current_year = timezone.now().year
|
||||||
timestamp_start__gte=datetime.now() - timedelta(days=30)
|
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
|
||||||
).order_by("-timestamp_start")
|
dataset = all_sessions.filter(timestamp_start__gte=first_day_of_year).order_by(
|
||||||
context["title"] = "Last 30 days"
|
"-timestamp_start"
|
||||||
|
)
|
||||||
|
context["title"] = "This year"
|
||||||
else:
|
else:
|
||||||
# by default, sort from newest to oldest
|
dataset = all_sessions
|
||||||
dataset = Session.objects.all().order_by("-timestamp_start")
|
|
||||||
|
|
||||||
for session in dataset:
|
context = {
|
||||||
if session.timestamp_end == None and session.duration_manual.seconds == 0:
|
**context,
|
||||||
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
"dataset": dataset,
|
||||||
session.unfinished = True
|
"dataset_count": dataset.count(),
|
||||||
|
"last": Session.objects.prefetch_related("purchase__platform").latest(),
|
||||||
context["total_duration"] = dataset.total_duration()
|
}
|
||||||
context["dataset"] = dataset
|
|
||||||
# cannot use dataset[0] here because that might be only partial QuerySet
|
|
||||||
context["last"] = Session.objects.all().order_by("timestamp_start").last()
|
|
||||||
# charts are always oldest->newest
|
|
||||||
if dataset.count() >= 2:
|
|
||||||
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
|
|
||||||
|
|
||||||
return render(request, "list_sessions.html", context)
|
return render(request, "list_sessions.html", context)
|
||||||
|
|
||||||
|
|
||||||
def add_purchase(request):
|
@login_required
|
||||||
|
def stats_alltime(request):
|
||||||
|
year = "Alltime"
|
||||||
|
this_year_sessions = Session.objects.all().select_related("purchase__edition")
|
||||||
|
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||||
|
duration=ExpressionWrapper(
|
||||||
|
F("timestamp_end") - F("timestamp_start"),
|
||||||
|
output_field=fields.DurationField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||||
|
this_year_games = Game.objects.filter(
|
||||||
|
edition__purchase__session__in=this_year_sessions
|
||||||
|
).distinct()
|
||||||
|
this_year_games_with_session_counts = this_year_games.annotate(
|
||||||
|
session_count=Count("edition__purchase__session"),
|
||||||
|
)
|
||||||
|
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||||
|
"-session_count"
|
||||||
|
).first()
|
||||||
|
selected_currency = "CZK"
|
||||||
|
unique_days = (
|
||||||
|
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||||
|
.values("date")
|
||||||
|
.distinct()
|
||||||
|
.aggregate(dates=Count("date"))
|
||||||
|
)
|
||||||
|
this_year_played_purchases = Purchase.objects.filter(
|
||||||
|
session__in=this_year_sessions
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
this_year_purchases = Purchase.objects.all()
|
||||||
|
this_year_purchases_with_currency = this_year_purchases.select_related(
|
||||||
|
"edition"
|
||||||
|
).filter(price_currency__exact=selected_currency)
|
||||||
|
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
||||||
|
date_refunded=None
|
||||||
|
)
|
||||||
|
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
|
||||||
|
|
||||||
|
this_year_purchases_unfinished_dropped_nondropped = (
|
||||||
|
this_year_purchases_without_refunded.filter(date_finished__isnull=True)
|
||||||
|
.filter(infinite=False)
|
||||||
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
|
) # do not count battle passes etc.
|
||||||
|
|
||||||
|
this_year_purchases_unfinished = (
|
||||||
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
|
date_dropped__isnull=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
this_year_purchases_dropped = (
|
||||||
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
|
date_dropped__isnull=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
this_year_purchases_without_refunded_count = (
|
||||||
|
this_year_purchases_without_refunded.count()
|
||||||
|
)
|
||||||
|
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
|
||||||
|
this_year_purchases_unfinished_percent = int(
|
||||||
|
safe_division(
|
||||||
|
this_year_purchases_unfinished_count,
|
||||||
|
this_year_purchases_without_refunded_count,
|
||||||
|
)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
|
||||||
|
purchases_finished_this_year = Purchase.objects.finished()
|
||||||
|
purchases_finished_this_year_released_this_year = (
|
||||||
|
purchases_finished_this_year.all().order_by("date_finished")
|
||||||
|
)
|
||||||
|
purchased_this_year_finished_this_year = (
|
||||||
|
this_year_purchases_without_refunded.all()
|
||||||
|
).order_by("date_finished")
|
||||||
|
|
||||||
|
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||||
|
total_spent=Sum(F("price"))
|
||||||
|
)
|
||||||
|
total_spent = this_year_spendings["total_spent"] or 0
|
||||||
|
|
||||||
|
games_with_playtime = (
|
||||||
|
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||||
|
.annotate(
|
||||||
|
total_playtime=Sum(
|
||||||
|
F("edition__purchase__session__duration_calculated")
|
||||||
|
+ F("edition__purchase__session__duration_manual")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("id", "name", "total_playtime")
|
||||||
|
)
|
||||||
|
month_playtimes = (
|
||||||
|
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||||
|
.values("month")
|
||||||
|
.annotate(playtime=Sum("duration_calculated"))
|
||||||
|
.order_by("month")
|
||||||
|
)
|
||||||
|
for month in month_playtimes:
|
||||||
|
month["playtime"] = format_duration(month["playtime"], "%2.0H")
|
||||||
|
|
||||||
|
highest_session_average_game = (
|
||||||
|
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||||
|
.annotate(
|
||||||
|
session_average=Avg("edition__purchase__session__duration_calculated")
|
||||||
|
)
|
||||||
|
.order_by("-session_average")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
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 = (
|
||||||
|
this_year_sessions.values("purchase__platform__name")
|
||||||
|
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
||||||
|
.annotate(platform_name=F("purchase__platform__name"))
|
||||||
|
.values("platform_name", "total_playtime")
|
||||||
|
.order_by("-total_playtime")
|
||||||
|
)
|
||||||
|
for item in total_playtime_per_platform:
|
||||||
|
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
||||||
|
|
||||||
|
backlog_decrease_count = (
|
||||||
|
Purchase.objects.all().intersection(purchases_finished_this_year).count()
|
||||||
|
)
|
||||||
|
|
||||||
|
first_play_name = "N/A"
|
||||||
|
first_play_date = "N/A"
|
||||||
|
last_play_name = "N/A"
|
||||||
|
last_play_date = "N/A"
|
||||||
|
if this_year_sessions:
|
||||||
|
first_session = this_year_sessions.earliest()
|
||||||
|
first_play_game = first_session.purchase.edition.game
|
||||||
|
first_play_date = first_session.timestamp_start.strftime("%x")
|
||||||
|
last_session = this_year_sessions.latest()
|
||||||
|
last_play_game = last_session.purchase.edition.game
|
||||||
|
last_play_date = last_session.timestamp_start.strftime("%x")
|
||||||
|
|
||||||
|
all_purchased_this_year_count = this_year_purchases_with_currency.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_percentage = int(
|
||||||
|
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"total_hours": format_duration(
|
||||||
|
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||||
|
),
|
||||||
|
"total_2023_games": this_year_played_purchases.all().count(),
|
||||||
|
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||||
|
"year": year,
|
||||||
|
"total_playtime_per_platform": total_playtime_per_platform,
|
||||||
|
"total_spent": total_spent,
|
||||||
|
"total_spent_currency": selected_currency,
|
||||||
|
"spent_per_game": int(
|
||||||
|
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||||
|
),
|
||||||
|
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||||
|
"total_sessions": this_year_sessions.count(),
|
||||||
|
"unique_days": unique_days["dates"],
|
||||||
|
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||||
|
"purchased_unfinished_count": this_year_purchases_unfinished_count,
|
||||||
|
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
||||||
|
"dropped_count": this_year_purchases_dropped_count,
|
||||||
|
"dropped_percentage": this_year_purchases_dropped_percentage,
|
||||||
|
"refunded_percent": int(
|
||||||
|
safe_division(
|
||||||
|
all_purchased_refunded_this_year_count,
|
||||||
|
all_purchased_this_year_count,
|
||||||
|
)
|
||||||
|
* 100
|
||||||
|
),
|
||||||
|
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||||
|
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||||
|
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||||
|
"backlog_decrease_count": backlog_decrease_count,
|
||||||
|
"longest_session_time": (
|
||||||
|
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
|
||||||
|
if longest_session
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"longest_session_game": (
|
||||||
|
longest_session.purchase.edition.game if longest_session else None
|
||||||
|
),
|
||||||
|
"highest_session_count": (
|
||||||
|
game_highest_session_count.session_count
|
||||||
|
if game_highest_session_count
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"highest_session_count_game": (
|
||||||
|
game_highest_session_count if game_highest_session_count else None
|
||||||
|
),
|
||||||
|
"highest_session_average": (
|
||||||
|
format_duration(
|
||||||
|
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||||
|
)
|
||||||
|
if highest_session_average_game
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"highest_session_average_game": highest_session_average_game,
|
||||||
|
"first_play_game": first_play_game,
|
||||||
|
"first_play_date": first_play_date,
|
||||||
|
"last_play_game": last_play_game,
|
||||||
|
"last_play_date": last_play_date,
|
||||||
|
"title": f"{year} Stats",
|
||||||
|
}
|
||||||
|
|
||||||
|
request.session["return_path"] = request.path
|
||||||
|
return render(request, "stats.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def stats(request, year: int = 0):
|
||||||
|
selected_year = request.GET.get("year")
|
||||||
|
if selected_year:
|
||||||
|
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
|
||||||
|
if year == 0:
|
||||||
|
return HttpResponseRedirect(reverse("stats_alltime"))
|
||||||
|
this_year_sessions = Session.objects.filter(
|
||||||
|
timestamp_start__year=year
|
||||||
|
).select_related("purchase__edition")
|
||||||
|
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||||
|
duration=ExpressionWrapper(
|
||||||
|
F("timestamp_end") - F("timestamp_start"),
|
||||||
|
output_field=fields.DurationField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||||
|
this_year_games = Game.objects.filter(
|
||||||
|
edition__purchase__session__in=this_year_sessions
|
||||||
|
).distinct()
|
||||||
|
this_year_games_with_session_counts = this_year_games.annotate(
|
||||||
|
session_count=Count(
|
||||||
|
"edition__purchase__session",
|
||||||
|
filter=Q(edition__purchase__session__timestamp_start__year=year),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||||
|
"-session_count"
|
||||||
|
).first()
|
||||||
|
selected_currency = "CZK"
|
||||||
|
unique_days = (
|
||||||
|
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||||
|
.values("date")
|
||||||
|
.distinct()
|
||||||
|
.aggregate(dates=Count("date"))
|
||||||
|
)
|
||||||
|
this_year_played_purchases = Purchase.objects.filter(
|
||||||
|
session__in=this_year_sessions
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
|
||||||
|
this_year_purchases_with_currency = this_year_purchases.select_related(
|
||||||
|
"edition"
|
||||||
|
).filter(price_currency__exact=selected_currency)
|
||||||
|
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
||||||
|
date_refunded=None
|
||||||
|
)
|
||||||
|
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
|
||||||
|
|
||||||
|
this_year_purchases_unfinished_dropped_nondropped = (
|
||||||
|
this_year_purchases_without_refunded.filter(date_finished__isnull=True)
|
||||||
|
.filter(infinite=False)
|
||||||
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
|
) # do not count battle passes etc.
|
||||||
|
|
||||||
|
this_year_purchases_unfinished = (
|
||||||
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
|
date_dropped__isnull=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
this_year_purchases_dropped = (
|
||||||
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
|
date_dropped__isnull=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
this_year_purchases_without_refunded_count = (
|
||||||
|
this_year_purchases_without_refunded.count()
|
||||||
|
)
|
||||||
|
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
|
||||||
|
this_year_purchases_unfinished_percent = int(
|
||||||
|
safe_division(
|
||||||
|
this_year_purchases_unfinished_count,
|
||||||
|
this_year_purchases_without_refunded_count,
|
||||||
|
)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
|
||||||
|
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
|
||||||
|
purchases_finished_this_year_released_this_year = (
|
||||||
|
purchases_finished_this_year.filter(edition__year_released=year).order_by(
|
||||||
|
"date_finished"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
purchased_this_year_finished_this_year = (
|
||||||
|
this_year_purchases_without_refunded.filter(date_finished__year=year)
|
||||||
|
).order_by("date_finished")
|
||||||
|
|
||||||
|
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||||
|
total_spent=Sum(F("price"))
|
||||||
|
)
|
||||||
|
total_spent = this_year_spendings["total_spent"] or 0
|
||||||
|
|
||||||
|
games_with_playtime = (
|
||||||
|
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||||
|
.annotate(
|
||||||
|
total_playtime=Sum(
|
||||||
|
F("edition__purchase__session__duration_calculated")
|
||||||
|
+ F("edition__purchase__session__duration_manual")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("id", "name", "total_playtime")
|
||||||
|
)
|
||||||
|
month_playtimes = (
|
||||||
|
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||||
|
.values("month")
|
||||||
|
.annotate(playtime=Sum("duration_calculated"))
|
||||||
|
.order_by("month")
|
||||||
|
)
|
||||||
|
for month in month_playtimes:
|
||||||
|
month["playtime"] = format_duration(month["playtime"], "%2.0H")
|
||||||
|
|
||||||
|
highest_session_average_game = (
|
||||||
|
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||||
|
.annotate(
|
||||||
|
session_average=Avg("edition__purchase__session__duration_calculated")
|
||||||
|
)
|
||||||
|
.order_by("-session_average")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
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 = (
|
||||||
|
this_year_sessions.values("purchase__platform__name")
|
||||||
|
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
||||||
|
.annotate(platform_name=F("purchase__platform__name"))
|
||||||
|
.values("platform_name", "total_playtime")
|
||||||
|
.order_by("-total_playtime")
|
||||||
|
)
|
||||||
|
for item in total_playtime_per_platform:
|
||||||
|
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
||||||
|
|
||||||
|
backlog_decrease_count = (
|
||||||
|
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||||
|
.intersection(purchases_finished_this_year)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
first_play_name = "N/A"
|
||||||
|
first_play_date = "N/A"
|
||||||
|
last_play_name = "N/A"
|
||||||
|
last_play_date = "N/A"
|
||||||
|
if this_year_sessions:
|
||||||
|
first_session = this_year_sessions.earliest()
|
||||||
|
first_play_game = first_session.purchase.edition.game
|
||||||
|
first_play_date = first_session.timestamp_start.strftime("%x")
|
||||||
|
last_session = this_year_sessions.latest()
|
||||||
|
last_play_game = last_session.purchase.edition.game
|
||||||
|
last_play_date = last_session.timestamp_start.strftime("%x")
|
||||||
|
|
||||||
|
all_purchased_this_year_count = this_year_purchases_with_currency.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_percentage = int(
|
||||||
|
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"total_hours": format_duration(
|
||||||
|
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||||
|
),
|
||||||
|
"total_games": this_year_played_purchases.count(),
|
||||||
|
"total_2023_games": this_year_played_purchases.filter(
|
||||||
|
edition__year_released=year
|
||||||
|
).count(),
|
||||||
|
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||||
|
"year": year,
|
||||||
|
"total_playtime_per_platform": total_playtime_per_platform,
|
||||||
|
"total_spent": total_spent,
|
||||||
|
"total_spent_currency": selected_currency,
|
||||||
|
"all_purchased_this_year": this_year_purchases_without_refunded,
|
||||||
|
"spent_per_game": int(
|
||||||
|
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||||
|
),
|
||||||
|
"all_finished_this_year": purchases_finished_this_year.select_related(
|
||||||
|
"edition"
|
||||||
|
).order_by("date_finished"),
|
||||||
|
"all_finished_this_year_count": purchases_finished_this_year.count(),
|
||||||
|
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
|
||||||
|
"edition"
|
||||||
|
).order_by(
|
||||||
|
"date_finished"
|
||||||
|
),
|
||||||
|
"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.select_related(
|
||||||
|
"edition"
|
||||||
|
).order_by(
|
||||||
|
"date_finished"
|
||||||
|
),
|
||||||
|
"total_sessions": this_year_sessions.count(),
|
||||||
|
"unique_days": unique_days["dates"],
|
||||||
|
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||||
|
"purchased_unfinished": this_year_purchases_unfinished,
|
||||||
|
"purchased_unfinished_count": this_year_purchases_unfinished_count,
|
||||||
|
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
||||||
|
"dropped_count": this_year_purchases_dropped_count,
|
||||||
|
"dropped_percentage": this_year_purchases_dropped_percentage,
|
||||||
|
"refunded_percent": int(
|
||||||
|
safe_division(
|
||||||
|
all_purchased_refunded_this_year_count,
|
||||||
|
all_purchased_this_year_count,
|
||||||
|
)
|
||||||
|
* 100
|
||||||
|
),
|
||||||
|
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||||
|
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||||
|
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
|
||||||
|
"date_purchased"
|
||||||
|
),
|
||||||
|
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||||
|
"backlog_decrease_count": backlog_decrease_count,
|
||||||
|
"longest_session_time": (
|
||||||
|
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
|
||||||
|
if longest_session
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"longest_session_game": (
|
||||||
|
longest_session.purchase.edition.game if longest_session else None
|
||||||
|
),
|
||||||
|
"highest_session_count": (
|
||||||
|
game_highest_session_count.session_count
|
||||||
|
if game_highest_session_count
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"highest_session_count_game": (
|
||||||
|
game_highest_session_count if game_highest_session_count else None
|
||||||
|
),
|
||||||
|
"highest_session_average": (
|
||||||
|
format_duration(
|
||||||
|
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||||
|
)
|
||||||
|
if highest_session_average_game
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"highest_session_average_game": highest_session_average_game,
|
||||||
|
"first_play_game": first_play_game,
|
||||||
|
"first_play_date": first_play_date,
|
||||||
|
"last_play_game": last_play_game,
|
||||||
|
"last_play_date": last_play_date,
|
||||||
|
"title": f"{year} Stats",
|
||||||
|
"month_playtimes": month_playtimes,
|
||||||
|
}
|
||||||
|
|
||||||
|
request.session["return_path"] = request.path
|
||||||
|
return render(request, "stats.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_purchase(request, purchase_id=None):
|
||||||
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
|
purchase.delete()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_purchase(request, edition_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
now = datetime.now()
|
initial = {"date_purchased": timezone.now()}
|
||||||
initial = {"date_purchased": now}
|
|
||||||
form = PurchaseForm(request.POST or None, initial=initial)
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
form = PurchaseForm(request.POST or None, initial=initial)
|
||||||
form.save()
|
if form.is_valid():
|
||||||
return redirect("index")
|
purchase = form.save()
|
||||||
|
if "submit_and_redirect" in request.POST:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse(
|
||||||
|
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect("index")
|
||||||
|
else:
|
||||||
|
if edition_id:
|
||||||
|
edition = Edition.objects.get(id=edition_id)
|
||||||
|
form = PurchaseForm(
|
||||||
|
initial={
|
||||||
|
**initial,
|
||||||
|
"edition": edition,
|
||||||
|
"platform": edition.platform,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = PurchaseForm(initial=initial)
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Purchase"
|
context["title"] = "Add New Purchase"
|
||||||
return render(request, "add.html", context)
|
context["script_name"] = "add_purchase.js"
|
||||||
|
return render(request, "add_purchase.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def add_game(request):
|
def add_game(request):
|
||||||
context = {}
|
context = {}
|
||||||
form = GameForm(request.POST or None)
|
form = GameForm(request.POST or None)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
game = form.save()
|
||||||
return redirect("index")
|
if "submit_and_redirect" in request.POST:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("add_edition_for_game", kwargs={"game_id": game.id})
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect("index")
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Game"
|
context["title"] = "Add New Game"
|
||||||
return render(request, "add.html", context)
|
context["script_name"] = "add_game.js"
|
||||||
|
return render(request, "add_game.html", context)
|
||||||
|
|
||||||
|
|
||||||
def add_edition(request):
|
@login_required
|
||||||
|
def add_edition(request, game_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
form = EditionForm(request.POST or None)
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
form = EditionForm(request.POST or None)
|
||||||
form.save()
|
if form.is_valid():
|
||||||
return redirect("index")
|
edition = form.save()
|
||||||
|
if "submit_and_redirect" in request.POST:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse(
|
||||||
|
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect("index")
|
||||||
|
else:
|
||||||
|
if game_id:
|
||||||
|
game = Game.objects.get(id=game_id)
|
||||||
|
form = EditionForm(
|
||||||
|
initial={
|
||||||
|
"game": game,
|
||||||
|
"name": game.name,
|
||||||
|
"sort_name": game.sort_name,
|
||||||
|
"year_released": game.year_released,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = EditionForm()
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Edition"
|
context["title"] = "Add New Edition"
|
||||||
return render(request, "add.html", context)
|
context["script_name"] = "add_edition.js"
|
||||||
|
return render(request, "add_edition.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def add_platform(request):
|
def add_platform(request):
|
||||||
context = {}
|
context = {}
|
||||||
form = PlatformForm(request.POST or None)
|
form = PlatformForm(request.POST or None)
|
||||||
@@ -236,6 +939,7 @@ def add_platform(request):
|
|||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def add_device(request):
|
def add_device(request):
|
||||||
context = {}
|
context = {}
|
||||||
form = DeviceForm(request.POST or None)
|
form = DeviceForm(request.POST or None)
|
||||||
@@ -248,5 +952,6 @@ def add_device(request):
|
|||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
return redirect("list_sessions_recent")
|
return redirect("list_sessions_recent")
|
||||||
|
|||||||
+8
-3
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.8",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"tailwindcss": "^3.2.4"
|
"concurrently": "^8.2.2",
|
||||||
|
"npm-check-updates": "^16.14.20",
|
||||||
|
"tailwindcss": "^3.4.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"flowbite": "^2.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+590
-917
File diff suppressed because it is too large
Load Diff
+27
-18
@@ -1,30 +1,39 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "timetracker"
|
name = "timetracker"
|
||||||
version = "1.0.3"
|
version = "1.5.2"
|
||||||
description = "A simple time tracker."
|
description = "A simple time tracker."
|
||||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
||||||
license = "GPL"
|
license = "GPL"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [{include = "timetracker"}]
|
packages = [{include = "timetracker"}]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
|
||||||
python = "^3.10"
|
|
||||||
django = "^4.1.4"
|
|
||||||
gunicorn = "^20.1.0"
|
|
||||||
uvicorn = "^0.20.0"
|
|
||||||
pandas = "^1.5.2"
|
|
||||||
matplotlib = "^3.6.3"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
black = "^22.12.0"
|
black = "^24.4.2"
|
||||||
mypy = "^0.991"
|
mypy = "^1.10.1"
|
||||||
pyyaml = "^6.0"
|
pyyaml = "^6.0.1"
|
||||||
pytest = "^7.2.0"
|
pytest = "^8.2.2"
|
||||||
django-extensions = "^3.2.1"
|
django-extensions = "^3.2.3"
|
||||||
werkzeug = "^2.2.2"
|
djhtml = "^3.0.6"
|
||||||
djhtml = "^1.5.2"
|
djlint = "^1.34.1"
|
||||||
djlint = "^1.19.11"
|
isort = "^5.13.2"
|
||||||
isort = "^5.11.4"
|
pre-commit = "^3.7.1"
|
||||||
|
django-debug-toolbar = "^4.4.2"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.11"
|
||||||
|
django = "^5.0.6"
|
||||||
|
gunicorn = "^22.0.0"
|
||||||
|
uvicorn = "^0.30.1"
|
||||||
|
graphene-django = "^3.2.2"
|
||||||
|
django-htmx = "^1.18.0"
|
||||||
|
django-template-partials = "^24.2"
|
||||||
|
markdown = "^3.6"
|
||||||
|
|
||||||
|
|
||||||
|
slippers = "^0.6.2"
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
|
|||||||
+18
-11
@@ -1,14 +1,21 @@
|
|||||||
|
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: ["./**/*.{html,js}"],
|
content: ["./games/**/*.{html,js}", './node_modules/flowbite/**/*.js'],
|
||||||
theme: {
|
theme: {
|
||||||
fontFamily: {
|
extend: {
|
||||||
sans: ['Inter', 'sans-serif'],
|
fontFamily: {
|
||||||
},
|
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
|
||||||
extend: {},
|
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
|
||||||
|
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
|
||||||
|
'condensed': ['IBM Plex Sans Condensed', ...defaultTheme.fontFamily.sans],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
},
|
||||||
require('@tailwindcss/typography'),
|
plugins: [
|
||||||
require('@tailwindcss/forms')
|
require('@tailwindcss/typography'),
|
||||||
],
|
require('@tailwindcss/forms'),
|
||||||
|
require('flowbite/plugin')
|
||||||
|
],
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import django
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from graphene_django.utils.testing import GraphQLTestCase
|
||||||
|
|
||||||
|
from games import schema
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
|
||||||
|
class GameAPITestCase(GraphQLTestCase):
|
||||||
|
GRAPHENE_SCHEMA = schema.schema
|
||||||
|
|
||||||
|
def test_query_all_games(self):
|
||||||
|
response = self.query(
|
||||||
|
"""
|
||||||
|
query {
|
||||||
|
games {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertResponseNoErrors(response)
|
||||||
|
self.assertEqual(
|
||||||
|
len(json.loads(response.content)["data"]["games"]),
|
||||||
|
Game.objects.count(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import django
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
||||||
|
django.setup()
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from games.models import Edition, Game, Platform, Purchase, Session
|
||||||
|
|
||||||
|
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||||
|
|
||||||
|
|
||||||
|
class PathWorksTest(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
pl = Platform(name="Test Platform")
|
||||||
|
pl.save()
|
||||||
|
g = Game(name="The Test Game")
|
||||||
|
g.save()
|
||||||
|
e = Edition(game=g, name="The Test Game Edition", platform=pl)
|
||||||
|
e.save()
|
||||||
|
p = Purchase(
|
||||||
|
edition=e,
|
||||||
|
platform=pl,
|
||||||
|
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||||
|
)
|
||||||
|
p.save()
|
||||||
|
s = Session(
|
||||||
|
purchase=p,
|
||||||
|
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||||
|
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
|
||||||
|
)
|
||||||
|
s.save()
|
||||||
|
self.testSession = s
|
||||||
|
return super().setUp()
|
||||||
|
|
||||||
|
def test_add_device_returns_200(self):
|
||||||
|
url = reverse("add_device")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_add_platform_returns_200(self):
|
||||||
|
url = reverse("add_platform")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_add_game_returns_200(self):
|
||||||
|
url = reverse("add_game")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_add_edition_returns_200(self):
|
||||||
|
url = reverse("add_edition")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_add_purchase_returns_200(self):
|
||||||
|
url = reverse("add_purchase")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_add_session_returns_200(self):
|
||||||
|
url = reverse("add_session")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_edit_session_returns_200(self):
|
||||||
|
id = self.testSession.id
|
||||||
|
url = reverse("edit_session", args=[id])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_view_game_returns_200(self):
|
||||||
|
url = reverse("view_game", args=[1])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_edit_game_returns_200(self):
|
||||||
|
url = reverse("edit_game", args=[1])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_list_sessions_returns_200(self):
|
||||||
|
url = reverse("list_sessions")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import django
|
||||||
|
from django.db import models
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
||||||
|
django.setup()
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from games.models import Edition, Game, Purchase, Session
|
||||||
|
|
||||||
|
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||||
|
|
||||||
|
|
||||||
|
class FormatDurationTest(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
return super().setUp()
|
||||||
|
|
||||||
|
def test_duration_format(self):
|
||||||
|
g = Game(name="The Test Game")
|
||||||
|
g.save()
|
||||||
|
e = Edition(game=g, name="The Test Game Edition")
|
||||||
|
e.save()
|
||||||
|
p = Purchase(
|
||||||
|
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
|
||||||
|
)
|
||||||
|
p.save()
|
||||||
|
s = Session(
|
||||||
|
purchase=p,
|
||||||
|
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||||
|
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
|
||||||
|
)
|
||||||
|
s.save()
|
||||||
|
self.assertEqual(
|
||||||
|
s.duration_formatted(),
|
||||||
|
"02:40",
|
||||||
|
)
|
||||||
+25
-1
@@ -6,7 +6,6 @@ from common.time import format_duration
|
|||||||
|
|
||||||
class FormatDurationTest(unittest.TestCase):
|
class FormatDurationTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
|
||||||
return super().setUp()
|
return super().setUp()
|
||||||
|
|
||||||
def test_only_days(self):
|
def test_only_days(self):
|
||||||
@@ -19,6 +18,21 @@ class FormatDurationTest(unittest.TestCase):
|
|||||||
result = format_duration(delta, "%H hours")
|
result = format_duration(delta, "%H hours")
|
||||||
self.assertEqual(result, "1 hours")
|
self.assertEqual(result, "1 hours")
|
||||||
|
|
||||||
|
def test_only_hours_fractional(self):
|
||||||
|
delta = timedelta(hours=1)
|
||||||
|
result = format_duration(delta, "%.1H hours")
|
||||||
|
self.assertEqual(result, "1.0 hours")
|
||||||
|
|
||||||
|
def test_less_than_hour_with_precision(self):
|
||||||
|
delta = timedelta(hours=0.5)
|
||||||
|
result = format_duration(delta, "%.1H hours")
|
||||||
|
self.assertEqual(result, "0.5 hours")
|
||||||
|
|
||||||
|
def test_less_than_hour_without_precision(self):
|
||||||
|
delta = timedelta(hours=0.5)
|
||||||
|
result = format_duration(delta, "%H hours")
|
||||||
|
self.assertEqual(result, "0 hours")
|
||||||
|
|
||||||
def test_overflow_hours(self):
|
def test_overflow_hours(self):
|
||||||
delta = timedelta(hours=25)
|
delta = timedelta(hours=25)
|
||||||
result = format_duration(delta, "%H hours")
|
result = format_duration(delta, "%H hours")
|
||||||
@@ -69,6 +83,16 @@ class FormatDurationTest(unittest.TestCase):
|
|||||||
result = format_duration(delta, "%r seconds")
|
result = format_duration(delta, "%r seconds")
|
||||||
self.assertEqual(result, "0 seconds")
|
self.assertEqual(result, "0 seconds")
|
||||||
|
|
||||||
|
def test_specific(self):
|
||||||
|
delta = timedelta(hours=2, minutes=40)
|
||||||
|
result = format_duration(delta, "%H:%m")
|
||||||
|
self.assertEqual(result, "2:40")
|
||||||
|
|
||||||
|
def test_specific_precise_if_unncessary(self):
|
||||||
|
delta = timedelta(hours=2, minutes=40)
|
||||||
|
result = format_duration(delta, "%02.0H:%02.0m")
|
||||||
|
self.assertEqual(result, "02:40")
|
||||||
|
|
||||||
def test_all_at_once(self):
|
def test_all_at_once(self):
|
||||||
delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
|
delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
|
||||||
result = format_duration(
|
result = format_duration(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user