Compare commits
153 Commits
add-fronte
...
98c9c1faee
Author | SHA1 | Date | |
---|---|---|---|
98c9c1faee
|
|||
645ffa0dad
|
|||
4358708262
|
|||
c738245783
|
|||
57184ceea0
|
|||
c2b9409562
|
|||
e067e65bce
|
|||
b8258e2937
|
|||
9af4c79947
|
|||
d8b8182b91
|
|||
2fd44c1f53
|
|||
c3f99d124c
|
|||
51f5b9fceb
|
|||
973f4416de
|
|||
a84209eb81
|
|||
498cd69328
|
|||
b28c42d945
|
|||
3099f02145
|
|||
74b9d0421c
|
|||
c61adad180
|
|||
298ecb4092
|
|||
020e12e20b
|
|||
6ef56bfed5
|
|||
fda4913c97
|
|||
e85b32e22f
|
|||
2d6d6d24a4
|
|||
00993a85db
|
|||
4f7e708255
|
|||
238e4839e0
|
|||
b0ad806a93
|
|||
453b4fd922
|
|||
bb0d24809e
|
|||
3abd4c4af9
|
|||
2e5e77b4e5
|
|||
e79cf5de7a
|
|||
c15eaca205
|
|||
496c99ccf1
|
|||
992622e8d1
|
|||
cabe36c822
|
|||
d84b67c460
|
|||
1c28950b53
|
|||
b54bcdd9e9
|
|||
9ec6c958c8
|
|||
25deac6ea9
|
|||
a5ac10b20d
|
|||
3de40ccad3
|
|||
6a5dc9b62c
|
|||
b6014a72e0
|
|||
245b47b8b3
|
|||
e33f23c18f
|
|||
33012bc328
|
|||
447bd4820c
|
|||
72e89dae77
|
|||
1cd0a8c0fb
|
|||
a9a430f856
|
|||
0ee4c50a24
|
|||
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
|
@ -30,7 +30,9 @@ steps:
|
|||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.kucharczyk.xyz/timetracker
|
repo: registry.kucharczyk.xyz/timetracker
|
||||||
auto_tag: true
|
tags:
|
||||||
|
- ${DRONE_COMMIT_REF}
|
||||||
|
- ${DRONE_COMMIT_BRANCH}
|
||||||
when:
|
when:
|
||||||
branch:
|
branch:
|
||||||
exclude:
|
exclude:
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
name: Django CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
paths-ignore: [ 'README.md' ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
registry.kucharczyk.xyz/timetracker:latest
|
|
||||||
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
|
||||||
env:
|
|
||||||
VERSION_NUMBER: 1.5.1
|
|
36
.github/workflows/build-docker.yml
vendored
Normal file
36
.github/workflows/build-docker.yml
vendored
Normal file
@ -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
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,9 +1,12 @@
|
|||||||
__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/
|
/static/
|
||||||
dist/
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
.python-version
|
||||||
|
.direnv
|
||||||
|
@ -1,10 +1,20 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/psf/black
|
# disable due to incomaptible formatting between
|
||||||
rev: 22.12.0
|
# black and ruff
|
||||||
hooks:
|
# TODO: replace with ruff when it works on NixOS
|
||||||
- id: black
|
# - repo: https://github.com/psf/black
|
||||||
|
# rev: 24.8.0
|
||||||
|
# hooks:
|
||||||
|
# - id: black
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: 5.12.0
|
rev: 5.13.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
name: isort (python)
|
name: isort (python)
|
||||||
|
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||||
|
rev: v1.34.0
|
||||||
|
hooks:
|
||||||
|
- id: djlint-reformat-django
|
||||||
|
args: ["--ignore", "H011"]
|
||||||
|
- id: djlint-django
|
||||||
|
args: ["--ignore", "H011"]
|
||||||
|
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"charliermarsh.ruff",
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"ms-python.debugpy",
|
||||||
|
"batisteo.vscode-django",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"EditorConfig.EditorConfig"
|
||||||
|
]
|
||||||
|
}
|
26
.vscode/settings.json
vendored
26
.vscode/settings.json
vendored
@ -4,8 +4,30 @@
|
|||||||
],
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"python.analysis.typeCheckingMode": "basic",
|
"python.analysis.typeCheckingMode": "strict",
|
||||||
"[python]": {
|
"[python]": {
|
||||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
"ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"],
|
||||||
|
"tailwind-fold.supportedLanguages": [
|
||||||
|
"html",
|
||||||
|
"typescriptreact",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"javascript",
|
||||||
|
"vue-html",
|
||||||
|
"vue",
|
||||||
|
"php",
|
||||||
|
"markdown",
|
||||||
|
"coffeescript",
|
||||||
|
"svelte",
|
||||||
|
"astro",
|
||||||
|
"erb",
|
||||||
|
"django-html"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
65
CHANGELOG.md
65
CHANGELOG.md
@ -1,5 +1,47 @@
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## New
|
||||||
|
* Render notes as Markdown
|
||||||
|
* Require login by default
|
||||||
|
* Add stats for dropped purchases, monthly playtimes
|
||||||
|
* Allow deleting purchases
|
||||||
|
* Add all-time stats
|
||||||
|
* Manage purchases
|
||||||
|
|
||||||
|
## 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
|
## New
|
||||||
* Add stat for finished this year's games
|
* Add stat for finished this year's games
|
||||||
* Add purchase types:
|
* Add purchase types:
|
||||||
@ -8,6 +50,9 @@
|
|||||||
* Season Pass
|
* Season Pass
|
||||||
* Battle Pass
|
* Battle Pass
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
* Order purchases by date on game view
|
||||||
|
|
||||||
## 1.4.0 / 2023-11-09 21:01+01:00
|
## 1.4.0 / 2023-11-09 21:01+01:00
|
||||||
|
|
||||||
### New
|
### New
|
||||||
@ -95,22 +140,24 @@
|
|||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
* Improve form appearance
|
* Improve form appearance
|
||||||
* Add helper buttons next to datime fields
|
|
||||||
* Change recent session view to current year instead of last 30 days
|
|
||||||
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
|
|
||||||
* Add copy button on Add session page to copy times between fields
|
|
||||||
* Use the same form when editing a session as when adding a session
|
|
||||||
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
|
|
||||||
* Focus important fields on forms
|
* 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)
|
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||||
* Change fonts to IBM Plex
|
|
||||||
* Only use local WOFF2 font files
|
### 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
|
||||||
|
* Add icons for game, edition, purchase filters
|
||||||
* Allow filtering by game, edition, purchase from the session list
|
* Allow filtering by game, edition, purchase from the session list
|
||||||
* Add icons for the above
|
* Allow editing filtered entities from session list
|
||||||
|
|
||||||
## 1.0.2 / 2023-02-18 21:48+01:00
|
## 1.0.2 / 2023-02-18 21:48+01:00
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
FROM python:3.12.0-slim-bullseye
|
FROM python:3.12.0-slim-bullseye
|
||||||
|
|
||||||
ENV VERSION_NUMBER=1.5.1 \
|
ENV VERSION_NUMBER=1.5.2 \
|
||||||
PROD=1 \
|
PROD=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONFAULTHANDLER=1 \
|
PYTHONFAULTHANDLER=1 \
|
||||||
|
24
Makefile
24
Makefile
@ -1,20 +1,36 @@
|
|||||||
all: migrate
|
all: css migrate
|
||||||
|
|
||||||
initialize: npm 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
|
||||||
|
|
||||||
|
css: common/input.css
|
||||||
|
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||||
|
|
||||||
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
|
||||||
|
14
README.md
14
README.md
@ -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`.
|
171
common/input.css
Normal file
171
common/input.css
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@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 {
|
||||||
|
@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 {
|
||||||
|
.min-w-20char {
|
||||||
|
min-width: 20ch;
|
||||||
|
}
|
||||||
|
.max-w-20char {
|
||||||
|
max-width: 20ch;
|
||||||
|
}
|
||||||
|
.min-w-30char {
|
||||||
|
min-width: 30ch;
|
||||||
|
}
|
||||||
|
.max-w-30char {
|
||||||
|
max-width: 30ch;
|
||||||
|
}
|
||||||
|
.max-w-35char {
|
||||||
|
max-width: 35ch;
|
||||||
|
}
|
||||||
|
.max-w-40char {
|
||||||
|
max-width: 40ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
form input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
@apply dark:bg-slate-700 dark:text-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorlist {
|
||||||
|
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
form input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
form input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-container button {
|
||||||
|
@apply mx-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,5 +1,13 @@
|
|||||||
import re
|
import re
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
dateformat: str = "%d/%m/%Y"
|
||||||
|
datetimeformat: str = "%d/%m/%Y %H:%M"
|
||||||
|
timeformat: str = "%H:%M"
|
||||||
|
durationformat: str = "%2.1H hours"
|
||||||
|
durationformat_manual: str = "%H hours"
|
||||||
|
|
||||||
|
|
||||||
def _safe_timedelta(duration: timedelta | int | None):
|
def _safe_timedelta(duration: timedelta | int | None):
|
||||||
@ -12,7 +20,7 @@ def _safe_timedelta(duration: timedelta | int | None):
|
|||||||
|
|
||||||
|
|
||||||
def format_duration(
|
def format_duration(
|
||||||
duration: timedelta | int | None, format_string: str = "%H hours"
|
duration: timedelta | int | float | None, format_string: str = "%H hours"
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Format timedelta into the specified format_string.
|
Format timedelta into the specified format_string.
|
||||||
@ -70,3 +78,9 @@ def format_duration(
|
|||||||
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
|
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
|
||||||
)
|
)
|
||||||
return formatted_string
|
return formatted_string
|
||||||
|
|
||||||
|
|
||||||
|
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
|
||||||
|
return timezone.localtime(datetime).strftime(format)
|
||||||
|
|
||||||
|
|
||||||
|
138
common/utils.py
138
common/utils.py
@ -1,3 +1,99 @@
|
|||||||
|
from random import choices
|
||||||
|
from string import ascii_lowercase
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import NoReverseMatch, reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
|
||||||
|
def Popover(
|
||||||
|
wrapped_content: str,
|
||||||
|
popover_content: str = "",
|
||||||
|
) -> str:
|
||||||
|
id = randomid()
|
||||||
|
if popover_content == "":
|
||||||
|
popover_content = wrapped_content
|
||||||
|
content = f"<span data-popover-target={id}>{wrapped_content}</span>"
|
||||||
|
result = mark_safe(
|
||||||
|
str(content)
|
||||||
|
+ render_to_string(
|
||||||
|
"cotton/popover.html",
|
||||||
|
{
|
||||||
|
"id": id,
|
||||||
|
"slot": popover_content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
HTMLAttribute = tuple[str, str]
|
||||||
|
HTMLTag = str
|
||||||
|
|
||||||
|
|
||||||
|
def Component(
|
||||||
|
attributes: list[HTMLAttribute] = [],
|
||||||
|
children: list[HTMLTag] | HTMLTag = [],
|
||||||
|
template: str = "",
|
||||||
|
tag_name: str = "",
|
||||||
|
) -> HTMLTag:
|
||||||
|
if not tag_name and not template:
|
||||||
|
raise ValueError("One of template or tag_name is required.")
|
||||||
|
if isinstance(children, str):
|
||||||
|
children = [children]
|
||||||
|
childrenBlob = "\n".join(children)
|
||||||
|
attributesList = [f'{name} = "{value}"' for name, value in attributes]
|
||||||
|
attributesBlob = " ".join(attributesList)
|
||||||
|
tag: str = ""
|
||||||
|
if tag_name != "":
|
||||||
|
tag = f"<a {attributesBlob}>{childrenBlob}</a>"
|
||||||
|
elif template != "":
|
||||||
|
tag = render_to_string(
|
||||||
|
template,
|
||||||
|
{name: value for name, value in attributes} | {"slot": "\n".join(children)},
|
||||||
|
)
|
||||||
|
return mark_safe(tag)
|
||||||
|
|
||||||
|
|
||||||
|
def A(
|
||||||
|
attributes: list[HTMLAttribute] = [],
|
||||||
|
children: list[HTMLTag] | HTMLTag = [],
|
||||||
|
url: str | Callable[..., Any] = "",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns the HTML tag "a".
|
||||||
|
"url" can either be:
|
||||||
|
- URL (string)
|
||||||
|
- path name passed to reverse() (string)
|
||||||
|
- function
|
||||||
|
"""
|
||||||
|
additional_attributes = []
|
||||||
|
if url:
|
||||||
|
if type(url) is str:
|
||||||
|
try:
|
||||||
|
url_result = reverse(url)
|
||||||
|
except NoReverseMatch:
|
||||||
|
url_result = url
|
||||||
|
elif callable(url):
|
||||||
|
url_result = url()
|
||||||
|
else:
|
||||||
|
raise TypeError("'url' is neither str nor function.")
|
||||||
|
additional_attributes = [("href", url_result)]
|
||||||
|
return Component(
|
||||||
|
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Button(
|
||||||
|
attributes: list[HTMLAttribute] = [],
|
||||||
|
children: list[HTMLTag] | HTMLTag = [],
|
||||||
|
):
|
||||||
|
return Component(
|
||||||
|
template="cotton/button.html", attributes=attributes, children=children
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
||||||
"""
|
"""
|
||||||
Divides without triggering division by zero exception.
|
Divides without triggering division by zero exception.
|
||||||
@ -7,3 +103,45 @@ def safe_division(numerator: int | float, denominator: int | float) -> int | flo
|
|||||||
return numerator / denominator
|
return numerator / denominator
|
||||||
except ZeroDivisionError:
|
except ZeroDivisionError:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
|
||||||
|
return (
|
||||||
|
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
|
||||||
|
if len(input_string) > 30
|
||||||
|
else input_string
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_with_popover(input_string: str) -> str:
|
||||||
|
if (truncated := truncate(input_string)) != input_string:
|
||||||
|
print(f"Not the same after: {truncated=}")
|
||||||
|
return Popover(wrapped_content=truncated, popover_content=input_string)
|
||||||
|
else:
|
||||||
|
print("Strings are the same!")
|
||||||
|
return input_string
|
||||||
|
|
||||||
|
|
||||||
|
def randomid(seed: str = "", length: int = 10) -> str:
|
||||||
|
return seed + "".join(choices(ascii_lowercase, k=length))
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
es2021: true
|
|
||||||
},
|
|
||||||
extends: ["eslint/recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
|
|
||||||
overrides: [],
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module"
|
|
||||||
},
|
|
||||||
plugins: ["react"],
|
|
||||||
rules: {},
|
|
||||||
parserOptions: {
|
|
||||||
ecmaFeatures: { jsx: true }
|
|
||||||
}
|
|
||||||
};
|
|
24
frontend/.gitignore
vendored
24
frontend/.gitignore
vendored
@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"printWidth": 100,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"singleAttributePerLine": true
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="description" content="Self-hosted time-tracker."/>
|
|
||||||
<meta name="keywords" content="time, tracking, video games, self-hosted"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<!-- TODO: replace with own icon -->
|
|
||||||
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
|
||||||
<title>Timetracker</title>
|
|
||||||
</head>
|
|
||||||
<body class="dark">
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"autoprefixer": "^10.4.13",
|
|
||||||
"postcss": "^8.4.21",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"tailwindcss": "^3.2.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.0.26",
|
|
||||||
"@types/react-dom": "^18.0.9",
|
|
||||||
"@vitejs/plugin-react": "^3.0.0",
|
|
||||||
"eslint": "^8.32.0",
|
|
||||||
"eslint-plugin-import": "^2.27.5",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
||||||
"eslint-plugin-react": "^7.32.1",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"vite": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="dark:bg-gray-800 min-h-screen">
|
|
||||||
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
|
||||||
<div className="container flex flex-wrap items-center justify-between mx-auto">
|
|
||||||
<a href="{% url 'index' %}" className="flex items-center">
|
|
||||||
<span className="text-4xl">⌚</span>
|
|
||||||
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
|
|
||||||
</a>
|
|
||||||
<div className="w-full md:block md:w-auto">
|
|
||||||
<ul
|
|
||||||
className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
|
|
||||||
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
|
|
||||||
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
|
|
||||||
{/* {% if game_available and platform_available %} */}
|
|
||||||
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
|
|
||||||
{/* {% endif %} */}
|
|
||||||
{/* {% if purchase_available %} */}
|
|
||||||
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
|
|
||||||
{/* {% endif %} */}
|
|
||||||
{/* {% if session_count > 0 %} */}
|
|
||||||
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
|
|
||||||
{/* {% endif %} */}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{/* {% block content %}No content here.{% endblock content %} */}
|
|
||||||
</div>
|
|
||||||
{/* {% load version %} */}
|
|
||||||
{/* <span className="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> */}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,71 +0,0 @@
|
|||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
function Nav() {
|
|
||||||
return (
|
|
||||||
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
|
||||||
<div className="container flex flex-wrap items-center justify-between mx-auto">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<span className="text-4xl">⌚</span>
|
|
||||||
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">
|
|
||||||
Timetracker
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<div className="w-full md:block md:w-auto">
|
|
||||||
<ul className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className="block py-2 pl-3 pr-4 hover:underline"
|
|
||||||
href="{% url 'add_game' %}"
|
|
||||||
>
|
|
||||||
New Game
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className="block py-2 pl-3 pr-4 hover:underline"
|
|
||||||
href="{% url 'add_platform' %}"
|
|
||||||
>
|
|
||||||
New Platform
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/* {% if game_available and platform_available %} */}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className="block py-2 pl-3 pr-4 hover:underline"
|
|
||||||
href="{% url 'add_purchase' %}"
|
|
||||||
>
|
|
||||||
New Purchase
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/* {% endif %} */}
|
|
||||||
{/* {% if purchase_available %} */}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className="block py-2 pl-3 pr-4 hover:underline"
|
|
||||||
href="{% url 'add_session' %}"
|
|
||||||
>
|
|
||||||
New Session
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/* {% endif %} */}
|
|
||||||
{/* {% if session_count > 0 %} */}
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
className="block py-2 pl-3 pr-4 hover:underline"
|
|
||||||
to="/sessions"
|
|
||||||
>
|
|
||||||
All Sessions
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
{/* {% endif %} */}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Nav;
|
|
@ -1,162 +0,0 @@
|
|||||||
export default function SessionList() {
|
|
||||||
const data = [
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/25/",
|
|
||||||
"timestamp_start": "2020-01-01T00:00:00+01:00",
|
|
||||||
"timestamp_end": null,
|
|
||||||
"duration_manual": "12:00:00",
|
|
||||||
"duration_calculated": "00:00:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/26/",
|
|
||||||
"timestamp_start": "2022-12-31T15:25:00+01:00",
|
|
||||||
"timestamp_end": "2022-12-31T17:25:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "02:00:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/2/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/27/",
|
|
||||||
"timestamp_start": "2023-01-01T23:00:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-02T00:28:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "01:28:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/28/",
|
|
||||||
"timestamp_start": "2023-01-02T22:08:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-03T01:08:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "03:00:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/29/",
|
|
||||||
"timestamp_start": "2023-01-03T22:36:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-04T00:12:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "01:36:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/30/",
|
|
||||||
"timestamp_start": "2023-01-04T20:35:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-04T22:36:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "02:01:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/31/",
|
|
||||||
"timestamp_start": "2023-01-06T18:48:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-06T23:39:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "04:51:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/32/",
|
|
||||||
"timestamp_start": "2023-01-07T23:49:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-08T01:43:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "01:54:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/33/",
|
|
||||||
"timestamp_start": "2023-01-08T16:21:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-08T18:27:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "02:06:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/34/",
|
|
||||||
"timestamp_start": "2023-01-08T19:04:00+01:00",
|
|
||||||
"timestamp_end": "2023-01-08T22:03:00+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "02:59:00",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/35/",
|
|
||||||
"timestamp_start": "2023-01-09T19:35:48+01:00",
|
|
||||||
"timestamp_end": "2023-01-09T22:13:20.519058+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "02:37:32.519058",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/3/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "http://localhost:8000/api/sessions/36/",
|
|
||||||
"timestamp_start": "2023-01-10T15:50:12+01:00",
|
|
||||||
"timestamp_end": "2023-01-10T17:03:45.424429+01:00",
|
|
||||||
"duration_manual": "00:00:00",
|
|
||||||
"duration_calculated": "01:13:33.424429",
|
|
||||||
"note": "",
|
|
||||||
"purchase": "http://localhost:8000/api/purchases/4/"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const header = ["url", "timestamp_start", "timestamp_end", "duration_manual", "duration_calculated", "note", "purchase"]
|
|
||||||
// const header = ["Name", "Platform", "Start", "End", "Duration", "Manage"]
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div id="session-table" className="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
|
|
||||||
{header.map(column => {
|
|
||||||
<div className="dark:border-white dark:text-slate-300 text-lg">{column}</div>
|
|
||||||
})}
|
|
||||||
{data.map(session => {
|
|
||||||
<>
|
|
||||||
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a className="hover:underline" href="">
|
|
||||||
{ session.url }
|
|
||||||
</a>
|
|
||||||
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a className="hover:underline" href="">
|
|
||||||
{ session.timestamp_start }
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a className="hover:underline" href="">
|
|
||||||
{ session.timestamp_end }
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a className="hover:underline" href="">
|
|
||||||
{ session.duration_manual }
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a className="hover:underline" href="">
|
|
||||||
{ session.duration_calculated }
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a className="hover:underline" href="">
|
|
||||||
{ session.note }
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<a className="hover:underline" href="">
|
|
||||||
{ session.purchase }
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
import { useRouteError } from "react-router-dom";
|
|
||||||
|
|
||||||
export default function ErrorPage() {
|
|
||||||
const error = useRouteError()
|
|
||||||
console.error(error)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container text-center">
|
|
||||||
<h1 className="text-3xl">Oops!</h1>
|
|
||||||
<p>Sorry, an unexpected error has occurred.</p>
|
|
||||||
<p>
|
|
||||||
<i>{error.statusText || error.message}</i>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
form label {
|
|
||||||
@apply dark:text-slate-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
form input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
#session-table {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
#button-container button {
|
|
||||||
@apply mx-1;
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App'
|
|
||||||
import './index.css'
|
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
|
||||||
// import { loader as sessionLoader } from './routes/sessions'
|
|
||||||
import ErrorPage from "./error-page"
|
|
||||||
import SessionList from './components/SessionList'
|
|
||||||
// import Session from './routes/sessions'
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
element: <App />,
|
|
||||||
errorElement: <ErrorPage />,
|
|
||||||
// loader: sessionLoader,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "sessions/",
|
|
||||||
element: <SessionList />
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// path: "sessions",
|
|
||||||
// element: <SessionList />
|
|
||||||
// }
|
|
||||||
])
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
@ -1,17 +0,0 @@
|
|||||||
export async function api(url) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (response.ok) {
|
|
||||||
const jsonValue = await response.json();
|
|
||||||
return Promise.resolve(jsonValue);
|
|
||||||
} else {
|
|
||||||
return Promise.reject('Response was not OK.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSession(sessionId) {
|
|
||||||
return await api(`/api/sessions/${sessionId}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSessionList() {
|
|
||||||
return await api(`/api/sessions/`);
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: "class",
|
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["Inter", "sans-serif"],
|
|
||||||
},
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
|
|
||||||
};
|
|
@ -1,12 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://127.0.0.1:8001",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,6 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.utils import safe_getattr
|
||||||
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||||
|
|
||||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||||
@ -9,11 +10,6 @@ custom_datetime_widget = forms.DateTimeInput(
|
|||||||
)
|
)
|
||||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||||
|
|
||||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
|
||||||
custom_datetime_widget = forms.DateTimeInput(
|
|
||||||
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionForm(forms.ModelForm):
|
class SessionForm(forms.ModelForm):
|
||||||
# purchase = forms.ModelChoiceField(
|
# purchase = forms.ModelChoiceField(
|
||||||
@ -50,8 +46,8 @@ class EditionChoiceField(forms.ModelChoiceField):
|
|||||||
class IncludePlatformSelect(forms.Select):
|
class IncludePlatformSelect(forms.Select):
|
||||||
def create_option(self, name, value, *args, **kwargs):
|
def create_option(self, name, value, *args, **kwargs):
|
||||||
option = super().create_option(name, value, *args, **kwargs)
|
option = super().create_option(name, value, *args, **kwargs)
|
||||||
if value:
|
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||||
option["attrs"]["data-platform"] = value.instance.platform.id
|
option["attrs"]["data-platform"] = platform_id
|
||||||
return option
|
return option
|
||||||
|
|
||||||
|
|
||||||
@ -87,6 +83,8 @@ class PurchaseForm(forms.ModelForm):
|
|||||||
widgets = {
|
widgets = {
|
||||||
"date_purchased": custom_date_widget,
|
"date_purchased": custom_date_widget,
|
||||||
"date_refunded": custom_date_widget,
|
"date_refunded": custom_date_widget,
|
||||||
|
"date_finished": custom_date_widget,
|
||||||
|
"date_dropped": custom_date_widget,
|
||||||
}
|
}
|
||||||
model = Purchase
|
model = Purchase
|
||||||
fields = [
|
fields = [
|
||||||
@ -95,6 +93,8 @@ class PurchaseForm(forms.ModelForm):
|
|||||||
"date_purchased",
|
"date_purchased",
|
||||||
"date_refunded",
|
"date_refunded",
|
||||||
"date_finished",
|
"date_finished",
|
||||||
|
"date_dropped",
|
||||||
|
"infinite",
|
||||||
"price",
|
"price",
|
||||||
"price_currency",
|
"price_currency",
|
||||||
"ownership_type",
|
"ownership_type",
|
||||||
@ -151,7 +151,7 @@ class EditionForm(forms.ModelForm):
|
|||||||
|
|
||||||
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):
|
||||||
|
1
games/graphql/mutations/__init__.py
Normal file
1
games/graphql/mutations/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .game import Mutation as GameMutation
|
29
games/graphql/mutations/game.py
Normal file
29
games/graphql/mutations/game.py
Normal file
@ -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()
|
6
games/graphql/queries/__init__.py
Normal file
6
games/graphql/queries/__init__.py
Normal file
@ -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
|
11
games/graphql/queries/device.py
Normal file
11
games/graphql/queries/device.py
Normal file
@ -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()
|
11
games/graphql/queries/edition.py
Normal file
11
games/graphql/queries/edition.py
Normal file
@ -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()
|
18
games/graphql/queries/game.py
Normal file
18
games/graphql/queries/game.py
Normal file
@ -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
|
11
games/graphql/queries/platform.py
Normal file
11
games/graphql/queries/platform.py
Normal file
@ -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()
|
11
games/graphql/queries/purchase.py
Normal file
11
games/graphql/queries/purchase.py
Normal file
@ -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()
|
11
games/graphql/queries/session.py
Normal file
11
games/graphql/queries/session.py
Normal file
@ -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()
|
44
games/graphql/types.py
Normal file
44
games/graphql/types.py
Normal file
@ -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,10 +1,11 @@
|
|||||||
# Generated by Django 4.1.5 on 2023-11-14 08:41
|
# Generated by Django 4.1.5 on 2023-11-14 08:41
|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("games", "0026_purchase_type"),
|
("games", "0026_purchase_type"),
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# Generated by Django 4.1.5 on 2023-11-14 11:05
|
# Generated by Django 4.1.5 on 2023-11-14 11:05
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
|
|
||||||
|
17
games/migrations/0033_alter_edition_unique_together.py
Normal file
17
games/migrations/0033_alter_edition_unique_together.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
25
games/migrations/0035_alter_session_device.py
Normal file
25
games/migrations/0035_alter_session_device.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.1 on 2024-08-11 15:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0034_purchase_date_dropped_purchase_infinite"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="session",
|
||||||
|
name="device",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||||
|
to="games.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
19
games/migrations/0036_alter_edition_platform.py
Normal file
19
games/migrations/0036_alter_edition_platform.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1 on 2024-08-11 16:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('games', '0035_alter_session_device'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='edition',
|
||||||
|
name='platform',
|
||||||
|
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform'),
|
||||||
|
),
|
||||||
|
]
|
111
games/models.py
111
games/models.py
@ -2,8 +2,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.db.models import F, Sum
|
||||||
from django.db.models import F, Manager, Sum
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from common.time import format_duration
|
from common.time import format_duration
|
||||||
@ -16,49 +15,39 @@ class Game(models.Model):
|
|||||||
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
session_average: float | int | timedelta | None
|
||||||
|
session_count: int | None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
def get_sort_name(name):
|
|
||||||
articles = ["a", "an", "the"]
|
|
||||||
name_parts = name.split()
|
|
||||||
first_word = name_parts[0].lower()
|
|
||||||
if first_word in articles:
|
|
||||||
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
|
|
||||||
else:
|
|
||||||
return name
|
|
||||||
|
|
||||||
self.sort_name = get_sort_name(self.name)
|
class Platform(models.Model):
|
||||||
super().save(*args, **kwargs)
|
name = models.CharField(max_length=255)
|
||||||
|
group = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Edition(models.Model):
|
class Edition(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [["name", "platform"]]
|
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.SET_DEFAULT, null=True, blank=True, default=None
|
||||||
|
)
|
||||||
|
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||||
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.sort_name
|
return self.sort_name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
def get_sort_name(name):
|
|
||||||
articles = ["a", "an", "the"]
|
|
||||||
name_parts = name.split()
|
|
||||||
first_word = name_parts[0].lower()
|
|
||||||
if first_word in articles:
|
|
||||||
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
|
|
||||||
else:
|
|
||||||
return name
|
|
||||||
|
|
||||||
self.sort_name = get_sort_name(self.name)
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class PurchaseQueryset(models.QuerySet):
|
class PurchaseQueryset(models.QuerySet):
|
||||||
def refunded(self):
|
def refunded(self):
|
||||||
@ -106,13 +95,15 @@ class Purchase(models.Model):
|
|||||||
|
|
||||||
objects = PurchaseQueryset().as_manager()
|
objects = PurchaseQueryset().as_manager()
|
||||||
|
|
||||||
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
|
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
|
||||||
platform = models.ForeignKey(
|
platform = models.ForeignKey(
|
||||||
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
|
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
|
||||||
)
|
)
|
||||||
date_purchased = models.DateField()
|
date_purchased = models.DateField()
|
||||||
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_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(
|
||||||
@ -121,14 +112,27 @@ class Purchase(models.Model):
|
|||||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||||
name = models.CharField(max_length=255, default="", null=True, blank=True)
|
name = models.CharField(max_length=255, default="", null=True, blank=True)
|
||||||
related_purchase = models.ForeignKey(
|
related_purchase = models.ForeignKey(
|
||||||
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
|
"self",
|
||||||
|
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):
|
def is_game(self):
|
||||||
return self.type == self.GAME
|
return self.type == self.GAME
|
||||||
@ -143,15 +147,6 @@ class Purchase(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Platform(models.Model):
|
|
||||||
name = models.CharField(max_length=255)
|
|
||||||
group = models.CharField(max_length=255, null=True, blank=True, default=None)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class SessionQuerySet(models.QuerySet):
|
class SessionQuerySet(models.QuerySet):
|
||||||
def total_duration_formatted(self):
|
def total_duration_formatted(self):
|
||||||
return format_duration(self.total_duration_unformatted())
|
return format_duration(self.total_duration_unformatted())
|
||||||
@ -162,19 +157,32 @@ class SessionQuerySet(models.QuerySet):
|
|||||||
)
|
)
|
||||||
return 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:
|
class Meta:
|
||||||
get_latest_by = "timestamp_start"
|
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 = models.ForeignKey(
|
||||||
"Device",
|
"Device",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_DEFAULT,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
default=None,
|
default=None,
|
||||||
@ -198,7 +206,7 @@ class Session(models.Model):
|
|||||||
def duration_seconds(self) -> timedelta:
|
def duration_seconds(self) -> timedelta:
|
||||||
manual = timedelta(0)
|
manual = timedelta(0)
|
||||||
calculated = timedelta(0)
|
calculated = timedelta(0)
|
||||||
if self.is_manual():
|
if 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
|
||||||
@ -215,12 +223,15 @@ class Session(models.Model):
|
|||||||
def duration_sum(self) -> str:
|
def duration_sum(self) -> str:
|
||||||
return Session.objects.all().total_duration_formatted()
|
return Session.objects.all().total_duration_formatted()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs) -> None:
|
||||||
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:
|
if not self.device:
|
||||||
default_device, _ = Device.objects.get_or_create(
|
default_device, _ = Device.objects.get_or_create(
|
||||||
type=Device.UNKNOWN, defaults={"name": "Unknown"}
|
type=Device.UNKNOWN, defaults={"name": "Unknown"}
|
||||||
|
30
games/schema.py
Normal file
30
games/schema.py
Normal file
@ -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)
|
File diff suppressed because it is too large
Load Diff
BIN
games/static/fonts/IBMPlexSansCondensed-Regular.woff2
Normal file
BIN
games/static/fonts/IBMPlexSansCondensed-Regular.woff2
Normal file
Binary file not shown.
BIN
games/static/fonts/IBMPlexSerif-Bold.woff2
Normal file
BIN
games/static/fonts/IBMPlexSerif-Bold.woff2
Normal file
Binary file not shown.
@ -1,29 +1,24 @@
|
|||||||
/**
|
import { syncSelectInputUntilChanged } from "./utils.js";
|
||||||
* @description Sync select field with input field until user focuses it.
|
|
||||||
* @param {HTMLSelectElement} sourceElementSelector
|
|
||||||
* @param {HTMLInputElement} targetElementSelector
|
|
||||||
*/
|
|
||||||
function syncSelectInputUntilChanged(
|
|
||||||
sourceElementSelector,
|
|
||||||
targetElementSelector
|
|
||||||
) {
|
|
||||||
const sourceElement = document.querySelector(sourceElementSelector);
|
|
||||||
const targetElement = document.querySelector(targetElementSelector);
|
|
||||||
function sourceElementHandler(event) {
|
|
||||||
let selected = event.target.value;
|
|
||||||
let selectedValue = document.querySelector(
|
|
||||||
`#id_game option[value='${selected}']`
|
|
||||||
).textContent;
|
|
||||||
targetElement.value = selectedValue;
|
|
||||||
}
|
|
||||||
function targetElementHandler(event) {
|
|
||||||
sourceElement.removeEventListener("change", sourceElementHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceElement.addEventListener("change", sourceElementHandler);
|
let syncData = [
|
||||||
targetElement.addEventListener("focus", targetElementHandler);
|
{
|
||||||
}
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
syncSelectInputUntilChanged(syncData, "form");
|
||||||
syncSelectInputUntilChanged("#id_game", "#id_name");
|
|
||||||
});
|
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { syncSelectInputUntilChanged } from './utils.js'
|
import { syncSelectInputUntilChanged } from "./utils.js";
|
||||||
|
|
||||||
let syncData = [
|
let syncData = [
|
||||||
{
|
{
|
||||||
"source": "#id_name",
|
source: "#id_name",
|
||||||
"source_value": "value",
|
source_value: "value",
|
||||||
"target": "#id_sort_name",
|
target: "#id_sort_name",
|
||||||
"target_value": "value"
|
target_value: "value",
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
syncSelectInputUntilChanged(syncData, "form")
|
syncSelectInputUntilChanged(syncData, "form");
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
|
import {
|
||||||
|
syncSelectInputUntilChanged,
|
||||||
|
getEl,
|
||||||
|
disableElementsWhenTrue,
|
||||||
|
disableElementsWhenValueNotEqual,
|
||||||
|
} from "./utils.js";
|
||||||
|
|
||||||
let syncData = [
|
let syncData = [
|
||||||
{
|
{
|
||||||
@ -11,21 +16,32 @@ let syncData = [
|
|||||||
|
|
||||||
syncSelectInputUntilChanged(syncData, "form");
|
syncSelectInputUntilChanged(syncData, "form");
|
||||||
|
|
||||||
|
function setupElementHandlers() {
|
||||||
let myConfig = [
|
disableElementsWhenTrue("#id_type", "game", [
|
||||||
() => {
|
"#id_name",
|
||||||
return getEl("#id_type").value == "game";
|
"#id_related_purchase",
|
||||||
},
|
]);
|
||||||
["#id_name", "#id_related_purchase"],
|
disableElementsWhenValueNotEqual(
|
||||||
(el) => {
|
"#id_type",
|
||||||
el.disabled = "disabled";
|
["game", "dlc"],
|
||||||
},
|
["#id_date_finished"]
|
||||||
(el) => {
|
);
|
||||||
el.disabled = "";
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
|
|
||||||
getEl("#id_type").onchange = () => {
|
|
||||||
conditionalElementHandler(...myConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
@ -7,7 +7,14 @@ for (let button of document.querySelectorAll("[data-target]")) {
|
|||||||
button.addEventListener("click", (event) => {
|
button.addEventListener("click", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (type == "now") {
|
if (type == "now") {
|
||||||
targetElement.value = toISOUTCString(new Date);
|
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") {
|
} else if (type == "toggle") {
|
||||||
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
||||||
else targetElement.type = "datetime-local";
|
else targetElement.type = "datetime-local";
|
||||||
|
@ -3,9 +3,16 @@
|
|||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function toISOUTCString(date) {
|
function toISOUTCString(date) {
|
||||||
let month = (date.getMonth() + 1).toString().padStart(2, 0);
|
function stringAndPad(number) {
|
||||||
return `${date.getFullYear()}-${month}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`;
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,7 +75,10 @@ function syncSelectInputUntilChanged(syncData, parentSelector = document) {
|
|||||||
* @param {string} property - The property to retrieve the value from.
|
* @param {string} property - The property to retrieve the value from.
|
||||||
*/
|
*/
|
||||||
function getValueFromProperty(sourceElement, property) {
|
function getValueFromProperty(sourceElement, property) {
|
||||||
let source = (sourceElement instanceof HTMLSelectElement) ? sourceElement.selectedOptions[0] : sourceElement
|
let source =
|
||||||
|
sourceElement instanceof HTMLSelectElement
|
||||||
|
? sourceElement.selectedOptions[0]
|
||||||
|
: sourceElement;
|
||||||
if (property.startsWith("dataset.")) {
|
if (property.startsWith("dataset.")) {
|
||||||
let datasetKey = property.slice(8); // Remove 'dataset.' part
|
let datasetKey = property.slice(8); // Remove 'dataset.' part
|
||||||
return source.dataset[datasetKey];
|
return source.dataset[datasetKey];
|
||||||
@ -86,43 +96,112 @@ function getValueFromProperty(sourceElement, property) {
|
|||||||
*/
|
*/
|
||||||
function getEl(selector) {
|
function getEl(selector) {
|
||||||
if (selector.startsWith("#")) {
|
if (selector.startsWith("#")) {
|
||||||
return document.getElementById(selector.slice(1))
|
return document.getElementById(selector.slice(1));
|
||||||
}
|
} else if (selector.startsWith(".")) {
|
||||||
else if (selector.startsWith(".")) {
|
return document.getElementsByClassName(selector);
|
||||||
return document.getElementsByClassName(selector)
|
} else {
|
||||||
}
|
return document.getElementsByTagName(selector);
|
||||||
else {
|
|
||||||
return document.getElementsByName(selector)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Does something to elements when something happens.
|
* @description Applies different behaviors to elements based on multiple conditional configurations.
|
||||||
* @param {() => boolean} condition The condition that is being tested.
|
* Each configuration is an array containing a condition function, an array of target element selectors,
|
||||||
* @param {string[]} targetElements
|
* and two callback functions for handling matched and unmatched conditions.
|
||||||
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches.
|
* @param {...Array} configs Each configuration is an array of the form:
|
||||||
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match.
|
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
|
||||||
|
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
|
||||||
|
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
|
||||||
|
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
|
||||||
*/
|
*/
|
||||||
function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) {
|
function conditionalElementHandler(...configs) {
|
||||||
if (condition()) {
|
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
|
||||||
targetElements.forEach((elementName) => {
|
if (condition()) {
|
||||||
let el = getEl(elementName);
|
targetElements.forEach((elementName) => {
|
||||||
if (el === null) {
|
let el = getEl(elementName);
|
||||||
console.error("Element ${elementName} doesn't exist.");
|
if (el === null) {
|
||||||
} else {
|
console.error(`Element ${elementName} doesn't exist.`);
|
||||||
callbackfn1(el);
|
} else {
|
||||||
}
|
callbackfn1(el);
|
||||||
});
|
}
|
||||||
} else {
|
});
|
||||||
targetElements.forEach((elementName) => {
|
} else {
|
||||||
let el = getEl(elementName);
|
targetElements.forEach((elementName) => {
|
||||||
if (el === null) {
|
let el = getEl(elementName);
|
||||||
console.error("Element ${elementName} doesn't exist.");
|
if (el === null) {
|
||||||
} else {
|
console.error(`Element ${elementName} doesn't exist.`);
|
||||||
callbackfn2(el);
|
} else {
|
||||||
}
|
callbackfn2(el);
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler };
|
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,
|
||||||
|
};
|
||||||
|
@ -6,13 +6,19 @@
|
|||||||
{% 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></td>
|
<td>
|
||||||
<td><input type="submit" value="Submit"/></td>
|
<input type="submit" value="Submit" />
|
||||||
</tr>
|
</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 %}
|
||||||
|
@ -6,17 +6,27 @@
|
|||||||
{% 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></td>
|
<td>
|
||||||
<td><input type="submit" value="Submit"/></td>
|
<input type="submit" name="submit" value="Submit" />
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit"
|
||||||
|
name="submit_and_redirect"
|
||||||
|
value="Submit & Create Purchase" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% load static %}
|
{% if script_name %}
|
||||||
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
@ -22,6 +22,16 @@
|
|||||||
value="Submit & Create Session" />
|
value="Submit & Create Session" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -1,34 +1,38 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
{% block title %}{{ title }}{% endblock 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 %}
|
||||||
|
{% for field in form %}
|
||||||
{% for field in form %}
|
<tr>
|
||||||
<tr>
|
<th>{{ field.label_tag }}</th>
|
||||||
<th>{{ field.label_tag }}</th>
|
{% if field.name == "note" %}
|
||||||
{% if field.name == "note" %}
|
<td>{{ field }}</td>
|
||||||
<td>{{ field }}</td>
|
{% else %}
|
||||||
{% else %}
|
<td>{{ field }}</td>
|
||||||
<td>{{ field }}</td>
|
{% endif %}
|
||||||
{% endif %}
|
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
||||||
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
<td>
|
||||||
<td>
|
<div class="basic-button-container" hx-boost="false">
|
||||||
<div class="basic-button-container">
|
<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="now">Set to now</button>
|
<button class="basic-button"
|
||||||
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
|
data-target="{{ field.name }}"
|
||||||
</div>
|
data-type="toggle">Toggle text</button>
|
||||||
</td>
|
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
|
||||||
{% endif %}
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
<tr>
|
</tr>
|
||||||
<td></td>
|
{% endfor %}
|
||||||
<td><input type="submit" value="Submit"/></td>
|
<tr>
|
||||||
</tr>
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="submit" value="Submit" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
@ -1,95 +1,90 @@
|
|||||||
|
{% load django_htmx %}
|
||||||
<!DOCTYPE html>
|
<!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 -
|
||||||
|
{% block title %}
|
||||||
|
Untitled
|
||||||
|
{% endblock title %}
|
||||||
|
</title>
|
||||||
<script src="{% static 'js/htmx.min.js' %}"></script>
|
<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>
|
||||||
|
<script>
|
||||||
|
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||||
|
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
<body hx-indicator="#indicator">
|
||||||
<body class="dark">
|
<img id="indicator"
|
||||||
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
|
src="{% static 'icons/loading.png' %}"
|
||||||
<div class="dark:bg-gray-800 min-h-screen">
|
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
||||||
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
height="24"
|
||||||
<div class="container flex flex-wrap items-center justify-between mx-auto">
|
width="24"
|
||||||
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
|
alt="loading indicator" />
|
||||||
<span class="text-4xl">⌚</span>
|
<div class="flex flex-col min-h-screen">
|
||||||
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
|
{% include "navbar.html" %}
|
||||||
</a>
|
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">
|
||||||
<div class="w-full md:block md:w-auto">
|
{% block content %}
|
||||||
<ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
|
No content here.
|
||||||
<li class="relative group">
|
{% endblock content %}
|
||||||
<a class="block py-2 pl-3 pr-4 hover:underline"
|
</div>
|
||||||
href="{% url 'add_game' %}">New</a>
|
{% load version %}
|
||||||
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
|
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
||||||
{% if purchase_available %}
|
|
||||||
<li>
|
|
||||||
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
|
||||||
href="{% url 'add_device' %}">Device</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>
|
|
||||||
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
|
|
||||||
href="{% url 'add_game' %}">Game</a>
|
|
||||||
</li>
|
|
||||||
{% if game_available and platform_available %}
|
|
||||||
<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_current_year' %}">Stats</a>
|
|
||||||
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
|
|
||||||
{% 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>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% block content %}
|
|
||||||
No content here.
|
|
||||||
{% endblock content %}
|
|
||||||
</div>
|
</div>
|
||||||
{% load version %}
|
|
||||||
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
</body>
|
<script>
|
||||||
|
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||||
|
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||||
|
|
||||||
|
// Change the icons inside the button based on previous settings
|
||||||
|
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
themeToggleLightIcon.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
themeToggleDarkIcon.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeToggleBtn = document.getElementById('theme-toggle');
|
||||||
|
|
||||||
|
themeToggleBtn.addEventListener('click', function () {
|
||||||
|
|
||||||
|
// toggle icons inside button
|
||||||
|
themeToggleDarkIcon.classList.toggle('hidden');
|
||||||
|
themeToggleLightIcon.classList.toggle('hidden');
|
||||||
|
|
||||||
|
// if set via local storage previously
|
||||||
|
if (localStorage.getItem('color-theme')) {
|
||||||
|
if (localStorage.getItem('color-theme') === 'light') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if NOT set via local storage previously
|
||||||
|
} else {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
<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>
|
|
@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
5
games/templates/cotton/button.html
Normal file
5
games/templates/cotton/button.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<button type="button"
|
||||||
|
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 mt-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
{{ text }}
|
||||||
|
{{ slot }}
|
||||||
|
</button>
|
20
games/templates/cotton/button_group_button_sm.html
Normal file
20
games/templates/cotton/button_group_button_sm.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<c-vars color="gray" />
|
||||||
|
<a href="{{ href }}"
|
||||||
|
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
|
||||||
|
{% if color == "gray" %}
|
||||||
|
<button type="button"
|
||||||
|
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||||
|
{{ text }}
|
||||||
|
</button>
|
||||||
|
{% elif color == "red" %}
|
||||||
|
<button type="button"
|
||||||
|
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||||
|
{{ text }}
|
||||||
|
</button>
|
||||||
|
{% elif color == "green" %}
|
||||||
|
<button type="button"
|
||||||
|
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white">
|
||||||
|
{{ text }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
6
games/templates/cotton/button_group_sm.html
Normal file
6
games/templates/cotton/button_group_sm.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<c-vars color="gray" />
|
||||||
|
<div class="inline-flex rounded-md shadow-sm" role="group">
|
||||||
|
{% for button in buttons %}
|
||||||
|
<c-button-group-button-sm :href=button.href :text=button.text :color=button.color />
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
13
games/templates/cotton/button_old.html
Normal file
13
games/templates/cotton/button_old.html
Normal file
@ -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>
|
@ -1,4 +1,4 @@
|
|||||||
{% comment %}
|
{% comment %}
|
||||||
title
|
title
|
||||||
text
|
text
|
||||||
{% endcomment %}
|
{% endcomment %}
|
10
games/templates/cotton/gamelink.html
Normal file
10
games/templates/cotton/gamelink.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<span class="truncate-container">
|
||||||
|
<a class="underline decoration-slate-500 sm:decoration-2"
|
||||||
|
href="{% url 'view_game' game_id %}">
|
||||||
|
{% if slot %}
|
||||||
|
{{ slot }}
|
||||||
|
{% else %}
|
||||||
|
{{ name }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</span>
|
8
games/templates/cotton/h1.html
Normal file
8
games/templates/cotton/h1.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
|
||||||
|
{{ slot }}
|
||||||
|
{% if badge %}
|
||||||
|
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2">
|
||||||
|
{{ badge }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
7
games/templates/cotton/popover.html
Normal file
7
games/templates/cotton/popover.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<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">{{ slot }}</div>
|
||||||
|
<div data-popper-arrow></div>
|
||||||
|
</div>
|
55
games/templates/cotton/simple_table.html
Normal file
55
games/templates/cotton/simple_table.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{% load param_utils %}
|
||||||
|
<div class="shadow-md" hx-boost="false">
|
||||||
|
<div class="relative overflow-x-auto sm:rounded-t-lg">
|
||||||
|
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
|
||||||
|
{% if header_action %}
|
||||||
|
<c-table-header>
|
||||||
|
{{ header_action }}
|
||||||
|
</c-table-header>
|
||||||
|
{% endif %}
|
||||||
|
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="dark:divide-y">
|
||||||
|
{% for row in rows %}<c-table-row :data=row />{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if page_obj and elided_page_range %}
|
||||||
|
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
|
||||||
|
aria-label="Table navigation">
|
||||||
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span>—<span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
|
||||||
|
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||||
|
<li>
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?{% param_replace page=page_obj.previous_page_number %}"
|
||||||
|
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<a aria-current="page"
|
||||||
|
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for page in elided_page_range %}
|
||||||
|
<li>
|
||||||
|
{% if page != page_obj.number %}
|
||||||
|
<a href="?{% param_replace page=page %}"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">{{ page }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a aria-current="page"
|
||||||
|
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-200">{{ page }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?{% param_replace page=page_obj.next_page_number %}"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Next</a>
|
||||||
|
{% else %}
|
||||||
|
<a aria-current="page"
|
||||||
|
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
12
games/templates/cotton/table.html
Normal file
12
games/templates/cotton/table.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||||
|
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
|
||||||
|
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ slot }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
3
games/templates/cotton/table_header.html
Normal file
3
games/templates/cotton/table_header.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<caption class="p-2 text-lg font-semibold rtl:text-left text-right text-gray-900 bg-white dark:text-white dark:bg-gray-900">
|
||||||
|
{{ slot }}
|
||||||
|
</caption>
|
16
games/templates/cotton/table_row.html
Normal file
16
games/templates/cotton/table_row.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2">
|
||||||
|
{% if slot %}
|
||||||
|
{{ slot }}
|
||||||
|
{% else %}
|
||||||
|
{% for td in data %}
|
||||||
|
{% if forloop.first %}
|
||||||
|
<th scope="row"
|
||||||
|
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
|
||||||
|
{% else %}
|
||||||
|
<c-table-td>
|
||||||
|
{{ td }}
|
||||||
|
</c-table-td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
1
games/templates/cotton/table_td.html
Normal file
1
games/templates/cotton/table_td.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<td class="px-6 py-4 min-w-20-char max-w-20-char">{{ slot }}</td>
|
10
games/templates/list_purchases.html
Normal file
10
games/templates/list_purchases.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}
|
||||||
|
{{ title }}
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
|
||||||
|
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
@ -1,73 +1,74 @@
|
|||||||
{% 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="flex-col">
|
||||||
{% if dataset.count >= 1 %}
|
{% if dataset_count >= 1 %}
|
||||||
<div class="mx-auto text-center my-4">
|
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
|
||||||
<a
|
<div class="mx-auto text-center my-4">
|
||||||
id="last-session-start"
|
<a id="last-session-start"
|
||||||
href="{% url 'start_session' last.id %}"
|
href="{{ start_session_url }}"
|
||||||
hx-get="{% url 'start_session' last.id %}"
|
hx-get="{{ start_session_url }}"
|
||||||
hx-indicator="#indicator"
|
hx-swap="afterbegin"
|
||||||
hx-swap="afterbegin"
|
hx-target=".responsive-table tbody"
|
||||||
hx-target=".responsive-table tbody"
|
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
|
||||||
hx-select=".responsive-table tbody tr:first-child"
|
class="{% if last.timestamp_end == null %}invisible{% endif %}">
|
||||||
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
|
{% include "components/button_start.html" with text=last.purchase title="Start session of last played game" only %}
|
||||||
class="{% if last.timestamp_end == null %}invisible{% endif %}"
|
</a>
|
||||||
>
|
</div>
|
||||||
{% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
|
|
||||||
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
|
|
||||||
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for data in dataset %}
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
|
|
||||||
>
|
|
||||||
{{ data.purchase.edition }}
|
|
||||||
</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
|
|
||||||
{{ data.timestamp_start | date:"d/m/Y H:i" }}
|
|
||||||
</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
|
|
||||||
{% if data.unfinished %}
|
|
||||||
<a
|
|
||||||
href="{% url 'update_session' data.id %}"
|
|
||||||
hx-get="{% url 'update_session' data.id %}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-target=".responsive-table tbody tr:first-child"
|
|
||||||
hx-select=".responsive-table tbody tr:first-child"
|
|
||||||
hx-indicator="#indicator"
|
|
||||||
onClick="document.querySelector('#last-session-start').classList.remove('invisible')"
|
|
||||||
>
|
|
||||||
<span class="text-yellow-300">Finish now?</span>
|
|
||||||
</a>
|
|
||||||
{% elif data.duration_manual %}
|
|
||||||
--
|
|
||||||
{% else %}
|
|
||||||
{{ data.timestamp_end | date:"d/m/Y H:i" }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
{% if dataset_count != 0 %}
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
<table class="responsive-table">
|
||||||
{{ data.duration_formatted }}
|
<thead>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
|
||||||
{% endfor %}
|
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
|
||||||
</tbody>
|
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
|
||||||
</table>
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for session in dataset %}
|
||||||
|
{% partialdef session-row inline=True %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
|
||||||
|
<span class="inline-block relative">
|
||||||
|
<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 %}">
|
||||||
|
{{ session.purchase.edition.name }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
|
||||||
|
{{ session.timestamp_start | date:"d/m/Y H:i" }}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
|
||||||
|
{% if not session.timestamp_end %}
|
||||||
|
{% url 'list_sessions_end_session' session.id as end_session_url %}
|
||||||
|
<a href="{{ end_session_url }}"
|
||||||
|
hx-get="{{ end_session_url }}"
|
||||||
|
hx-target="closest tr"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="#indicator"
|
||||||
|
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
|
||||||
|
<span class="text-yellow-300">Finish now?</span>
|
||||||
|
</a>
|
||||||
|
{% elif session.duration_manual %}
|
||||||
|
--
|
||||||
|
{% else %}
|
||||||
|
{{ session.timestamp_end | date:"d/m/Y H:i" }}
|
||||||
|
{% 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>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
136
games/templates/navbar.html
Normal file
136
games/templates/navbar.html
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
{% load static %}
|
||||||
|
<nav class="bg-white border-gray-200 dark:bg-gray-900 dark:border-gray-700">
|
||||||
|
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
||||||
|
<a href="{% url 'index' %}"
|
||||||
|
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||||
|
<img src="{% static 'icons/schedule.png' %}"
|
||||||
|
height="48"
|
||||||
|
width="48"
|
||||||
|
alt="Timetracker Logo"
|
||||||
|
class="mr-4" />
|
||||||
|
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
|
||||||
|
</a>
|
||||||
|
<button data-collapse-toggle="navbar-dropdown"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||||
|
aria-controls="navbar-dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<span class="sr-only">Open main menu</span>
|
||||||
|
<svg class="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 17 14">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
||||||
|
<ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||||
|
<li>
|
||||||
|
<a href="#"
|
||||||
|
class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
|
||||||
|
aria-current="page">Home</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="dropdownNavbarNewLink"
|
||||||
|
data-dropdown-toggle="dropdownNavbarNew"
|
||||||
|
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent">
|
||||||
|
New
|
||||||
|
<svg class="w-2.5 h-2.5 ms-2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 10 6">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Dropdown menu -->
|
||||||
|
<div id="dropdownNavbarNew"
|
||||||
|
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||||
|
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
||||||
|
aria-labelledby="dropdownLargeButton">
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'add_device' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'add_game' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'add_edition' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Edition</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'add_platform' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'add_purchase' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'add_session' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="dropdownNavbarManageLink"
|
||||||
|
data-dropdown-toggle="dropdownNavbarManage"
|
||||||
|
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent">
|
||||||
|
Manage
|
||||||
|
<svg class="w-2.5 h-2.5 ms-2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 10 6">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Dropdown menu -->
|
||||||
|
<div id="dropdownNavbarManage"
|
||||||
|
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||||
|
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
||||||
|
aria-labelledby="dropdownLargeButton">
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'list_devices' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'list_games' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'list_editions' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'list_platforms' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'list_purchases' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'list_sessions' %}"
|
||||||
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'stats_by_year' 0 %}"
|
||||||
|
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'logout' %}"
|
||||||
|
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log
|
||||||
|
out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
22
games/templates/registration/login.html
Normal file
22
games/templates/registration/login.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{% 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 %}
|
@ -3,6 +3,15 @@
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
{% endblock title %}
|
{% endblock title %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% partialdef purchase-name %}
|
||||||
|
{% if purchase.type != 'game' %}
|
||||||
|
<c-gamelink :game_id=purchase.edition.game.id>
|
||||||
|
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
|
||||||
|
</c-gamelink>
|
||||||
|
{% else %}
|
||||||
|
<c-gamelink :game_id=purchase.edition.game.id name=purchase.edition.name />
|
||||||
|
{% endif %}
|
||||||
|
{% endpartialdef %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
||||||
<div class="flex justify-center items-center">
|
<div class="flex justify-center items-center">
|
||||||
@ -18,74 +27,119 @@
|
|||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-column flex-wrap justify-center">
|
<h1 class="text-5xl text-center my-6">Playtime</h1>
|
||||||
<div class="md:w-1/2">
|
<table class="responsive-table">
|
||||||
<h1 class="text-5xl text-center my-6">Playtime</h1>
|
<tbody>
|
||||||
<table class="responsive-table">
|
<tr>
|
||||||
<tbody>
|
<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 }} (<c-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 }} (<c-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 }} (<c-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">
|
||||||
|
<c-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">
|
||||||
|
<c-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>
|
<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">{{ month.month | date:"F" }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
{% endfor %}
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
|
</tbody>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
|
</table>
|
||||||
</tr>
|
{% endif %}
|
||||||
<tr>
|
<h1 class="text-5xl text-center my-6">Purchases</h1>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
|
<table class="responsive-table">
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
|
<tbody>
|
||||||
</tr>
|
<tr>
|
||||||
<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">Games</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
|
</tr>
|
||||||
</tr>
|
<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">Games ({{ year }})</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
|
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
|
||||||
</tr>
|
<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">Finished ({{ year }})</td>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
|
||||||
</tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
</tbody>
|
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
|
||||||
</table>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
<div class="md:w-1/2">
|
<tr>
|
||||||
<h1 class="text-5xl text-center my-6">Purchases</h1>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
|
||||||
<table class="responsive-table">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
|
||||||
<tbody>
|
</tr>
|
||||||
<tr>
|
<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">Spendings ({{ total_spent_currency }})</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
</tbody>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
|
</table>
|
||||||
<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">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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
|
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
|
||||||
<table class="responsive-table">
|
<table class="responsive-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -98,8 +152,7 @@
|
|||||||
{% for game in top_10_games_by_playtime %}
|
{% for game in top_10_games_by_playtime %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
<c-gamelink :game_id=game.id :name=game.name />
|
||||||
href="{% url 'view_game' game.id %}">{{ game.name }}</a>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -123,91 +176,104 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h1 class="text-5xl text-center my-6">Finished</h1>
|
{% if all_finished_this_year %}
|
||||||
<table class="responsive-table">
|
<h1 class="text-5xl text-center my-6">Finished</h1>
|
||||||
<thead>
|
<table class="responsive-table">
|
||||||
<tr>
|
<thead>
|
||||||
<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>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
|
|
||||||
</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>
|
</tr>
|
||||||
{% endfor %}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{% for purchase in all_finished_this_year %}
|
||||||
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
|
<tr>
|
||||||
<table class="responsive-table">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||||
<thead>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
{% endfor %}
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
</thead>
|
{% endif %}
|
||||||
<tbody>
|
{% if this_year_finished_this_year %}
|
||||||
{% for purchase in this_year_finished_this_year %}
|
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
|
|
||||||
</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>
|
</tr>
|
||||||
{% endfor %}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{% for purchase in this_year_finished_this_year %}
|
||||||
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
|
<tr>
|
||||||
<table class="responsive-table">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||||
<thead>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
{% endfor %}
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
</thead>
|
{% endif %}
|
||||||
<tbody>
|
{% if purchased_this_year_finished_this_year %}
|
||||||
{% for purchase in 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>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
|
|
||||||
</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>
|
</tr>
|
||||||
{% endfor %}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{% for purchase in purchased_this_year_finished_this_year %}
|
||||||
<h1 class="text-5xl text-center my-6">All Purchases</h1>
|
<tr>
|
||||||
<table class="responsive-table">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||||
<thead>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
{% endfor %}
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
|
</tbody>
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
</table>
|
||||||
</tr>
|
{% endif %}
|
||||||
</thead>
|
{% if purchased_unfinished %}
|
||||||
<tbody>
|
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
|
||||||
{% for purchase in all_purchased_this_year %}
|
<table class="responsive-table">
|
||||||
<tr>
|
<thead>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
<tr>
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
{{ purchase.edition.name }}
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
|
||||||
{% if purchase.type != "game" %}
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
({{ purchase.name }}, {{ purchase.get_type_display }})
|
</tr>
|
||||||
{% endif %}
|
</thead>
|
||||||
</a>
|
<tbody>
|
||||||
</td>
|
{% for purchase in purchased_unfinished %}
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||||
</tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
|
||||||
{% endfor %}
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
{% 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>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -3,81 +3,109 @@
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
{% endblock title %}
|
{% endblock title %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load markdown_extras %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
||||||
<h1 class="text-4xl flex items-center">
|
<div id="game-info" class="mb-10">
|
||||||
{{ game.name }}
|
<div class="flex gap-5 mb-3">
|
||||||
<span class="dark:text-slate-500">(#{{ game.pk }})</span>
|
<span class="text-wrap max-w-80 text-4xl">
|
||||||
{% url 'edit_game' game.id as edit_url %}
|
<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>
|
||||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
<c-popover id="popover-year">
|
||||||
</h1>
|
Original release year
|
||||||
<h2 class="text-lg my-2 ml-2">
|
</c-popover>
|
||||||
{{ hours_sum }} <span class="dark:text-slate-500">total</span>
|
|
||||||
{{ session_average }} <span class="dark:text-slate-500">avg</span>
|
|
||||||
({{ playrange }})
|
|
||||||
</h2>
|
|
||||||
<hr class="border-slate-500">
|
|
||||||
<h1 class="text-3xl mt-4 mb-1">
|
|
||||||
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
|
|
||||||
</h1>
|
|
||||||
<ul>
|
|
||||||
{% for edition in editions %}
|
|
||||||
<li class="sm:pl-2 flex items-center">
|
|
||||||
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
|
|
||||||
{% if edition.wikidata %}
|
|
||||||
<span class="hidden sm:inline">
|
|
||||||
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
|
|
||||||
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
|
|
||||||
</a>
|
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
</div>
|
||||||
{% url 'edit_edition' edition.id as edit_url %}
|
<div class="flex gap-4 dark:text-slate-400 mb-3">
|
||||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
<span data-popover-target="popover-hours" class="flex gap-2 items-center">
|
||||||
</li>
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
{% endfor %}
|
fill="none"
|
||||||
</ul>
|
viewBox="0 0 24 24"
|
||||||
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
|
stroke-width="1.5"
|
||||||
<ul>
|
stroke="currentColor"
|
||||||
{% for purchase in purchases %}
|
class="size-6">
|
||||||
<li class="sm:pl-2 flex items-center">
|
<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" />
|
||||||
{{ purchase.platform }}
|
</svg>
|
||||||
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
|
{{ hours_sum }}
|
||||||
{% url 'edit_purchase' purchase.id as edit_url %}
|
<c-popover id="popover-hours">
|
||||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
Total hours played
|
||||||
{% if purchase.related_purchases %}
|
</c-popover>
|
||||||
<li>
|
</span>
|
||||||
<ul>
|
<span data-popover-target="popover-sessions"
|
||||||
{% for related_purchase in purchase.related_purchases %}
|
class="flex gap-2 items-center">
|
||||||
<li class="sm:pl-6 flex items-center">
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
|
fill="none"
|
||||||
{% url 'edit_purchase' related_purchase.id as edit_url %}
|
viewBox="0 0 24 24"
|
||||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
stroke-width="1.5"
|
||||||
</li>
|
stroke="currentColor"
|
||||||
{% endfor %}
|
class="size-6">
|
||||||
</ul>
|
<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" />
|
||||||
</li>
|
</svg>
|
||||||
{% endif %}
|
{{ session_count }}
|
||||||
</li>
|
<c-popover id="popover-sessions">
|
||||||
{% endfor %}
|
Number of sessions
|
||||||
</ul>
|
</c-popover>
|
||||||
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
|
</span>
|
||||||
Sessions
|
<span data-popover-target="popover-average" class="flex gap-2 items-center">
|
||||||
<span class="dark:text-slate-500">({{ sessions.count }})</span>
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
{% url 'start_game_session' game.id as add_session_link %}
|
fill="none"
|
||||||
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
|
viewBox="0 0 24 24"
|
||||||
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
</h1>
|
class="size-6">
|
||||||
<ul>
|
<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" />
|
||||||
{% for session in sessions %}
|
</svg>
|
||||||
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center">
|
{{ session_average_without_manual }}
|
||||||
{{ session.timestamp_start | date:"d/m/Y H:m" }}
|
<c-popover id="popover-average">
|
||||||
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
|
Average playtime per session
|
||||||
{% url 'edit_session' session.id as edit_url %}
|
</c-popover>
|
||||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
</span>
|
||||||
</li>
|
<span data-popover-target="popover-playrange"
|
||||||
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
|
class="flex gap-2 items-center">
|
||||||
{% endfor %}
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
</ul>
|
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 }}
|
||||||
|
<c-popover id="popover-playrange">
|
||||||
|
Earliest and latest dates played
|
||||||
|
</c-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>
|
||||||
|
<c-h1 :badge=edition_count>Editions</c-h1>
|
||||||
|
<div class="mb-6">
|
||||||
|
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<c-h1 :badge=purchase_count>Purchases</c-h1>
|
||||||
|
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<c-h1 :badge=session_count>Sessions</c-h1>
|
||||||
|
<c-simple-table :rows=session_data.rows :columns=session_data.columns :page_obj=session_page_obj :elided_page_range=session_elided_page_range />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
function getSessionCount() {
|
||||||
|
return document.getElementById('session-count').textContent.match("[0-9]+");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
10
games/templatetags/markdown_extras.py
Normal file
10
games/templatetags/markdown_extras.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import markdown
|
||||||
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="markdown")
|
||||||
|
def markdown_format(text):
|
||||||
|
return mark_safe(markdown.markdown(text))
|
18
games/templatetags/param_utils.py
Normal file
18
games/templatetags/param_utils.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.http import QueryDict
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def param_replace(context: dict[Any, Any], **kwargs):
|
||||||
|
"""
|
||||||
|
Return encoded URL parameters that are the same as the current
|
||||||
|
request's parameters, only with the specified GET parameters added or changed.
|
||||||
|
"""
|
||||||
|
d: QueryDict = context["request"].GET.copy()
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
d[k] = v
|
||||||
|
return d.urlencode()
|
11
games/templatetags/randomid.py
Normal file
11
games/templatetags/randomid.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def randomid(seed: str = "") -> str:
|
||||||
|
return str(hash(seed + "".join(random.choices(string.ascii_lowercase, k=10))))
|
166
games/urls.py
166
games/urls.py
@ -1,96 +1,110 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from games import views
|
from games.views import device, edition, game, general, platform, purchase, session
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="index"),
|
path("", general.index, name="index"),
|
||||||
|
path("device/add", device.add_device, name="add_device"),
|
||||||
|
path("device/delete/<int:device_id>", device.delete_device, name="delete_device"),
|
||||||
|
path("device/edit/<int:device_id>", device.edit_device, name="edit_device"),
|
||||||
|
path("device/list", device.list_devices, name="list_devices"),
|
||||||
|
path("edition/add", edition.add_edition, name="add_edition"),
|
||||||
path(
|
path(
|
||||||
"list-sessions/recent",
|
"edition/add/for-game/<int:game_id>",
|
||||||
views.list_sessions,
|
edition.add_edition,
|
||||||
{"filter": "recent"},
|
name="add_edition_for_game",
|
||||||
name="list_sessions_recent",
|
|
||||||
),
|
),
|
||||||
path("add-game/", views.add_game, name="add_game"),
|
path("edition/<int:edition_id>/edit", edition.edit_edition, name="edit_edition"),
|
||||||
path("add-platform/", views.add_platform, name="add_platform"),
|
path("edition/list", edition.list_editions, name="list_editions"),
|
||||||
path("add-session/", views.add_session, name="add_session"),
|
|
||||||
path(
|
path(
|
||||||
"add-session-for-purchase/<int:purchase_id>",
|
"edition/<int:edition_id>/delete",
|
||||||
views.add_session,
|
edition.delete_edition,
|
||||||
|
name="delete_edition",
|
||||||
|
),
|
||||||
|
path("game/add", game.add_game, name="add_game"),
|
||||||
|
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
|
||||||
|
path("game/<int:game_id>/view", game.view_game, name="view_game"),
|
||||||
|
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
|
||||||
|
path("game/list", game.list_games, name="list_games"),
|
||||||
|
path("platform/add", platform.add_platform, name="add_platform"),
|
||||||
|
path(
|
||||||
|
"platform/<int:platform_id>/edit",
|
||||||
|
platform.edit_platform,
|
||||||
|
name="edit_platform",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"platform/<int:platform_id>/delete",
|
||||||
|
platform.delete_platform,
|
||||||
|
name="delete_platform",
|
||||||
|
),
|
||||||
|
path("platform/list", platform.list_platforms, name="list_platforms"),
|
||||||
|
path("purchase/add", purchase.add_purchase, name="add_purchase"),
|
||||||
|
path(
|
||||||
|
"purchase/<int:purchase_id>/edit",
|
||||||
|
purchase.edit_purchase,
|
||||||
|
name="edit_purchase",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"purchase/<int:purchase_id>/delete",
|
||||||
|
purchase.delete_purchase,
|
||||||
|
name="delete_purchase",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"purchase/list",
|
||||||
|
purchase.list_purchases,
|
||||||
|
name="list_purchases",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"purchase/related-purchase-by-edition",
|
||||||
|
purchase.related_purchase_by_edition,
|
||||||
|
name="related_purchase_by_edition",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"purchase/add/for-edition/<int:edition_id>",
|
||||||
|
purchase.add_purchase,
|
||||||
|
name="add_purchase_for_edition",
|
||||||
|
),
|
||||||
|
path("session/add", session.add_session, name="add_session"),
|
||||||
|
path(
|
||||||
|
"session/add/for-purchase/<int:purchase_id>",
|
||||||
|
session.add_session,
|
||||||
name="add_session_for_purchase",
|
name="add_session_for_purchase",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"update-session/by-session/<int:session_id>",
|
"session/add/from-game/<int:session_id>",
|
||||||
views.update_session,
|
session.new_session_from_existing_session,
|
||||||
name="update_session",
|
{"template": "view_game.html#session-info"},
|
||||||
|
name="view_game_start_session_from_session",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"start-session-same-as-last/<int:last_session_id>",
|
"session/add/from-list/<int:session_id>",
|
||||||
views.start_session_same_as_last,
|
session.new_session_from_existing_session,
|
||||||
name="start_session_same_as_last",
|
{"template": "list_sessions.html#session-row"},
|
||||||
|
name="list_sessions_start_session_from_session",
|
||||||
|
),
|
||||||
|
path("session/<int:session_id>/edit", session.edit_session, name="edit_session"),
|
||||||
|
path(
|
||||||
|
"session/<int:session_id>/delete",
|
||||||
|
session.delete_session,
|
||||||
|
name="delete_session",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"start-session/<int:game_id>",
|
"session/end/from-game/<int:session_id>",
|
||||||
views.start_game_session,
|
session.end_session,
|
||||||
name="start_game_session",
|
{"template": "view_game.html#session-info"},
|
||||||
),
|
name="view_game_end_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-purchase-for-edition/<int:edition_id>",
|
|
||||||
views.add_purchase,
|
|
||||||
name="add_purchase_for_edition",
|
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"related-purchase-by-edition",
|
"session/end/from-list/<int:session_id>",
|
||||||
views.related_purchase_by_edition,
|
session.end_session,
|
||||||
name="related_purchase_by_edition",
|
{"template": "list_sessions.html#session-row"},
|
||||||
|
name="list_sessions_end_session",
|
||||||
),
|
),
|
||||||
path("add-edition/", views.add_edition, name="add_edition"),
|
path("session/list", session.list_sessions, name="list_sessions"),
|
||||||
|
path("stats/", general.stats_alltime, name="stats_alltime"),
|
||||||
path(
|
path(
|
||||||
"add-edition-for-game/<int:game_id>",
|
"stats/<int:year>",
|
||||||
views.add_edition,
|
general.stats,
|
||||||
name="add_edition_for_game",
|
name="stats_by_year",
|
||||||
),
|
|
||||||
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
|
|
||||||
path("game/<int:game_id>/view", views.view_game, name="view_game"),
|
|
||||||
path("game/<int:game_id>/edit", 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,
|
|
||||||
{"filter": "purchase"},
|
|
||||||
name="list_sessions_by_purchase",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"list-sessions/by-platform/<int:platform_id>",
|
|
||||||
views.list_sessions,
|
|
||||||
{"filter": "platform"},
|
|
||||||
name="list_sessions_by_platform",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"list-sessions/by-game/<int:game_id>",
|
|
||||||
views.list_sessions,
|
|
||||||
{"filter": "game"},
|
|
||||||
name="list_sessions_by_game",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"list-sessions/by-edition/<int:edition_id>",
|
|
||||||
views.list_sessions,
|
|
||||||
{"filter": "edition"},
|
|
||||||
name="list_sessions_by_edition",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"list-sessions/by-ownership/<str:ownership_type>",
|
|
||||||
views.list_sessions,
|
|
||||||
{"filter": "ownership_type"},
|
|
||||||
name="list_sessions_by_ownership_type",
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
545
games/views.py
545
games/views.py
@ -1,545 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from common.time import now as now_with_tz
|
|
||||||
from django.conf import settings
|
|
||||||
from django.shortcuts import redirect, render
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from common.time import format_duration
|
|
||||||
from common.utils import safe_division
|
|
||||||
|
|
||||||
from .forms import (
|
|
||||||
DeviceForm,
|
|
||||||
EditionForm,
|
|
||||||
GameForm,
|
|
||||||
PlatformForm,
|
|
||||||
PurchaseForm,
|
|
||||||
SessionForm,
|
|
||||||
)
|
|
||||||
from .models import Edition, Game, Platform, Purchase, Session
|
|
||||||
|
|
||||||
|
|
||||||
def model_counts(request):
|
|
||||||
return {
|
|
||||||
"game_available": Game.objects.count() != 0,
|
|
||||||
"edition_available": Edition.objects.count() != 0,
|
|
||||||
"platform_available": Platform.objects.count() != 0,
|
|
||||||
"purchase_available": Purchase.objects.count() != 0,
|
|
||||||
"session_count": Session.objects.count(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def stats_dropdown_year_range(request):
|
|
||||||
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def add_session(request, purchase_id=None):
|
|
||||||
context = {}
|
|
||||||
initial = {"timestamp_start": timezone.now()}
|
|
||||||
|
|
||||||
last = Session.objects.last()
|
|
||||||
if last != None:
|
|
||||||
initial["purchase"] = last.purchase
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
form = SessionForm(request.POST or None, initial=initial)
|
|
||||||
if form.is_valid():
|
|
||||||
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["form"] = form
|
|
||||||
return render(request, "add_session.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def update_session(request, session_id=None):
|
|
||||||
session = Session.objects.get(id=session_id)
|
|
||||||
session.finish_now()
|
|
||||||
session.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
|
|
||||||
|
|
||||||
def use_custom_redirect(
|
|
||||||
func: Callable[..., HttpResponse]
|
|
||||||
) -> Callable[..., HttpResponse]:
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@use_custom_redirect
|
|
||||||
def edit_session(request, session_id=None):
|
|
||||||
context = {}
|
|
||||||
session = Session.objects.get(id=session_id)
|
|
||||||
form = SessionForm(request.POST or None, instance=session)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
context["title"] = "Edit Session"
|
|
||||||
context["form"] = form
|
|
||||||
return render(request, "add_session.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@use_custom_redirect
|
|
||||||
def edit_purchase(request, purchase_id=None):
|
|
||||||
context = {}
|
|
||||||
purchase = Purchase.objects.get(id=purchase_id)
|
|
||||||
form = PurchaseForm(request.POST or None, instance=purchase)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
context["title"] = "Edit Purchase"
|
|
||||||
context["form"] = form
|
|
||||||
context["script_name"] = "add_purchase.js"
|
|
||||||
return render(request, "add_purchase.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@use_custom_redirect
|
|
||||||
def edit_game(request, game_id=None):
|
|
||||||
context = {}
|
|
||||||
purchase = Game.objects.get(id=game_id)
|
|
||||||
form = GameForm(request.POST or None, instance=purchase)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
context["title"] = "Edit Game"
|
|
||||||
context["form"] = form
|
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def view_game(request, game_id=None):
|
|
||||||
game = Game.objects.get(id=game_id)
|
|
||||||
context["title"] = "View Game"
|
|
||||||
context["game"] = game
|
|
||||||
context["editions"] = Edition.objects.filter(game_id=game_id)
|
|
||||||
game_purchases = Purchase.objects.filter(edition__game_id=game_id).filter(
|
|
||||||
type=Purchase.GAME
|
|
||||||
)
|
|
||||||
for purchase in game_purchases:
|
|
||||||
purchase.related_purchases = Purchase.objects.exclude(
|
|
||||||
type=Purchase.GAME
|
|
||||||
).filter(related_purchase=purchase.id)
|
|
||||||
|
|
||||||
context["purchases"] = game_purchases
|
|
||||||
context["sessions"] = Session.objects.filter(
|
|
||||||
purchase__edition__game_id=game_id
|
|
||||||
).order_by("-timestamp_start")
|
|
||||||
context["total_hours"] = float(
|
|
||||||
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
|
|
||||||
)
|
|
||||||
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.filter(purchase__edition__game=game)
|
|
||||||
session_count = sessions.count()
|
|
||||||
|
|
||||||
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
|
|
||||||
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
|
|
||||||
|
|
||||||
playrange = (
|
|
||||||
playrange_start
|
|
||||||
if playrange_start == playrange_end
|
|
||||||
else f"{playrange_start} — {playrange_end}"
|
|
||||||
)
|
|
||||||
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"edition_count": editions.count(),
|
|
||||||
"editions": editions,
|
|
||||||
"game": game,
|
|
||||||
"playrange": playrange,
|
|
||||||
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
|
|
||||||
"session_average": round(total_hours / int(session_count), 1),
|
|
||||||
"session_count": session_count,
|
|
||||||
"sessions_with_notes_count": sessions.exclude(note="").count(),
|
|
||||||
"sessions": sessions.order_by("-timestamp_start"),
|
|
||||||
"title": f"Game Overview - {game.name}",
|
|
||||||
"hours_sum": total_hours,
|
|
||||||
}
|
|
||||||
|
|
||||||
request.session["return_path"] = request.path
|
|
||||||
return render(request, "view_game.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@use_custom_redirect
|
|
||||||
def edit_platform(request, platform_id=None):
|
|
||||||
context = {}
|
|
||||||
purchase = Platform.objects.get(id=platform_id)
|
|
||||||
form = PlatformForm(request.POST or None, instance=purchase)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
context["title"] = "Edit Platform"
|
|
||||||
context["form"] = form
|
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@use_custom_redirect
|
|
||||||
def edit_edition(request, edition_id=None):
|
|
||||||
context = {}
|
|
||||||
edition = Edition.objects.get(id=edition_id)
|
|
||||||
form = EditionForm(request.POST or None, instance=edition)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
context["title"] = "Edit Edition"
|
|
||||||
context["form"] = form
|
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def related_purchase_by_edition(request):
|
|
||||||
edition_id = request.GET.get("edition")
|
|
||||||
if not edition_id:
|
|
||||||
return HttpResponseBadRequest("Invalid edition_id")
|
|
||||||
form = PurchaseForm()
|
|
||||||
form.fields["related_purchase"].queryset = Purchase.objects.filter(
|
|
||||||
edition_id=edition_id, type=Purchase.GAME
|
|
||||||
).order_by("edition__sort_name")
|
|
||||||
return render(request, "partials/related_purchase_field.html", {"form": form})
|
|
||||||
|
|
||||||
|
|
||||||
@use_custom_redirect
|
|
||||||
def start_game_session(request, game_id: int):
|
|
||||||
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
|
|
||||||
session = SessionForm(
|
|
||||||
{
|
|
||||||
"purchase": last_session.purchase.id,
|
|
||||||
"timestamp_start": timezone.now(),
|
|
||||||
"device": last_session.device,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
|
|
||||||
|
|
||||||
def start_session_same_as_last(request, last_session_id: int):
|
|
||||||
last_session = Session.objects.get(id=last_session_id)
|
|
||||||
session = SessionForm(
|
|
||||||
{
|
|
||||||
"purchase": last_session.purchase.id,
|
|
||||||
"timestamp_start": timezone.now(),
|
|
||||||
"device": last_session.device,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.save()
|
|
||||||
return redirect("list_sessions")
|
|
||||||
|
|
||||||
|
|
||||||
def list_sessions(
|
|
||||||
request,
|
|
||||||
filter="",
|
|
||||||
purchase_id="",
|
|
||||||
platform_id="",
|
|
||||||
game_id="",
|
|
||||||
edition_id="",
|
|
||||||
ownership_type: str = "",
|
|
||||||
):
|
|
||||||
context = {}
|
|
||||||
context["title"] = "Sessions"
|
|
||||||
|
|
||||||
if filter == "purchase":
|
|
||||||
dataset = Session.objects.filter(purchase=purchase_id)
|
|
||||||
context["purchase"] = Purchase.objects.get(id=purchase_id)
|
|
||||||
elif filter == "platform":
|
|
||||||
dataset = Session.objects.filter(purchase__platform=platform_id)
|
|
||||||
context["platform"] = Platform.objects.get(id=platform_id)
|
|
||||||
elif filter == "edition":
|
|
||||||
dataset = Session.objects.filter(purchase__edition=edition_id)
|
|
||||||
context["edition"] = Edition.objects.get(id=edition_id)
|
|
||||||
elif filter == "game":
|
|
||||||
dataset = Session.objects.filter(purchase__edition__game=game_id)
|
|
||||||
context["game"] = Game.objects.get(id=game_id)
|
|
||||||
elif filter == "ownership_type":
|
|
||||||
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
|
|
||||||
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
|
|
||||||
elif filter == "recent":
|
|
||||||
dataset = Session.objects.filter(
|
|
||||||
timestamp_start__gte=datetime.now() - timedelta(days=30)
|
|
||||||
)
|
|
||||||
context["title"] = "Last 30 days"
|
|
||||||
else:
|
|
||||||
# by default, sort from newest to oldest
|
|
||||||
dataset = Session.objects.order_by("-timestamp_start")
|
|
||||||
|
|
||||||
for session in dataset:
|
|
||||||
if session.timestamp_end == None and session.duration_manual == timedelta(
|
|
||||||
seconds=0
|
|
||||||
):
|
|
||||||
session.timestamp_end = timezone.now()
|
|
||||||
session.unfinished = True
|
|
||||||
|
|
||||||
context["total_duration"] = dataset.total_duration_formatted()
|
|
||||||
context["dataset"] = dataset
|
|
||||||
# cannot use dataset[0] here because that might be only partial QuerySet
|
|
||||||
context["last"] = Session.objects.all().order_by("timestamp_start").last()
|
|
||||||
|
|
||||||
return render(request, "list_sessions.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
year = timezone.now().year
|
|
||||||
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
|
|
||||||
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.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 = this_year_purchases_without_refunded.filter(
|
|
||||||
date_finished__isnull=True
|
|
||||||
).filter(
|
|
||||||
type=Purchase.GAME
|
|
||||||
) # do not count DLC etc.
|
|
||||||
|
|
||||||
this_year_purchases_unfinished_percent = int(
|
|
||||||
safe_division(
|
|
||||||
this_year_purchases_unfinished.count(), this_year_purchases_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.intersection(
|
|
||||||
purchases_finished_this_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")
|
|
||||||
)
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
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.order_by(
|
|
||||||
"date_finished"
|
|
||||||
),
|
|
||||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
|
|
||||||
"date_finished"
|
|
||||||
),
|
|
||||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
|
|
||||||
"date_finished"
|
|
||||||
),
|
|
||||||
"total_sessions": this_year_sessions.count(),
|
|
||||||
"unique_days": unique_days["dates"],
|
|
||||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
|
||||||
"purchased_unfinished": this_year_purchases_unfinished,
|
|
||||||
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
|
||||||
"refunded_percent": int(
|
|
||||||
safe_division(
|
|
||||||
this_year_purchases_refunded.count(),
|
|
||||||
this_year_purchases_with_currency.count(),
|
|
||||||
)
|
|
||||||
* 100
|
|
||||||
),
|
|
||||||
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
|
||||||
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
|
|
||||||
"date_purchased"
|
|
||||||
),
|
|
||||||
"backlog_decrease_count": backlog_decrease_count,
|
|
||||||
}
|
|
||||||
|
|
||||||
request.session["return_path"] = request.path
|
|
||||||
return render(request, "stats.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def add_purchase(request, edition_id=None):
|
|
||||||
context = {}
|
|
||||||
initial = {"date_purchased": timezone.now()}
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
form = PurchaseForm(request.POST or None, initial=initial)
|
|
||||||
if form.is_valid():
|
|
||||||
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["title"] = "Add New Purchase"
|
|
||||||
context["script_name"] = "add_purchase.js"
|
|
||||||
return render(request, "add_purchase.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def add_game(request):
|
|
||||||
context = {}
|
|
||||||
form = GameForm(request.POST or None)
|
|
||||||
if form.is_valid():
|
|
||||||
game = form.save()
|
|
||||||
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["title"] = "Add New Game"
|
|
||||||
context["script_name"] = "add_game.js"
|
|
||||||
return render(request, "add_game.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def add_edition(request, game_id=None):
|
|
||||||
context = {}
|
|
||||||
if request.method == "POST":
|
|
||||||
form = EditionForm(request.POST or None)
|
|
||||||
if form.is_valid():
|
|
||||||
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["title"] = "Add New Edition"
|
|
||||||
context["script_name"] = "add_edition.js"
|
|
||||||
return render(request, "add_edition.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def add_platform(request):
|
|
||||||
context = {}
|
|
||||||
form = PlatformForm(request.POST or None)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect("index")
|
|
||||||
|
|
||||||
context["form"] = form
|
|
||||||
context["title"] = "Add New Platform"
|
|
||||||
return render(request, "add.html", context)
|
|
105
games/views/device.py
Normal file
105
games/views/device.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.time import dateformat, local_strftime
|
||||||
|
from common.utils import A, Button
|
||||||
|
from games.forms import DeviceForm
|
||||||
|
from games.models import Device
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_devices(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[Any, Any] = {}
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
limit = request.GET.get("limit", 10)
|
||||||
|
devices = Device.objects.order_by("-created_at")
|
||||||
|
page_obj = None
|
||||||
|
if int(limit) != 0:
|
||||||
|
paginator = Paginator(devices, limit)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
devices = page_obj.object_list
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Manage devices",
|
||||||
|
"page_obj": page_obj or None,
|
||||||
|
"elided_page_range": (
|
||||||
|
page_obj.paginator.get_elided_page_range(
|
||||||
|
page_number, on_each_side=1, on_ends=1
|
||||||
|
)
|
||||||
|
if page_obj
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"data": {
|
||||||
|
"header_action": A([], Button([], "Add device"), url="add_device"),
|
||||||
|
"columns": [
|
||||||
|
"Name",
|
||||||
|
"Type",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
device.name,
|
||||||
|
device.get_type_display(),
|
||||||
|
local_strftime(device.created_at, dateformat),
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse("edit_device", args=[device.pk]),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse("delete_device", args=[device.pk]),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for device in devices
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, "list_purchases.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
|
||||||
|
device = get_object_or_404(Device, id=device_id)
|
||||||
|
form = DeviceForm(request.POST or None, instance=device)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("list_devices")
|
||||||
|
|
||||||
|
context: dict[str, Any] = {"form": form, "title": "Edit device"}
|
||||||
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
|
||||||
|
device = get_object_or_404(Device, id=device_id)
|
||||||
|
device.delete()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_device(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[str, Any] = {}
|
||||||
|
form = DeviceForm(request.POST or None)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("index")
|
||||||
|
|
||||||
|
context["form"] = form
|
||||||
|
context["title"] = "Add New Device"
|
||||||
|
return render(request, "add.html", context)
|
157
games/views/edition.py
Normal file
157
games/views/edition.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.time import dateformat, local_strftime
|
||||||
|
from common.utils import A, Button, truncate_with_popover
|
||||||
|
from games.forms import EditionForm
|
||||||
|
from games.models import Edition, Game
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_editions(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[Any, Any] = {}
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
limit = request.GET.get("limit", 10)
|
||||||
|
editions = Edition.objects.order_by("-created_at")
|
||||||
|
page_obj = None
|
||||||
|
if int(limit) != 0:
|
||||||
|
paginator = Paginator(editions, limit)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
editions = page_obj.object_list
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Manage editions",
|
||||||
|
"page_obj": page_obj or None,
|
||||||
|
"elided_page_range": (
|
||||||
|
page_obj.paginator.get_elided_page_range(
|
||||||
|
page_number, on_each_side=1, on_ends=1
|
||||||
|
)
|
||||||
|
if page_obj
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"data": {
|
||||||
|
"header_action": A([], Button([], "Add edition"), url="add_edition"),
|
||||||
|
"columns": [
|
||||||
|
"Game",
|
||||||
|
"Name",
|
||||||
|
"Sort Name",
|
||||||
|
"Platform",
|
||||||
|
"Year",
|
||||||
|
"Wikidata",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
A(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"href",
|
||||||
|
reverse(
|
||||||
|
"view_game",
|
||||||
|
args=[edition.game.pk],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
truncate_with_popover(edition.game.name),
|
||||||
|
),
|
||||||
|
truncate_with_popover(
|
||||||
|
edition.name
|
||||||
|
if edition.game.name != edition.name
|
||||||
|
else "(identical)"
|
||||||
|
),
|
||||||
|
truncate_with_popover(
|
||||||
|
edition.sort_name
|
||||||
|
if edition.sort_name is not None
|
||||||
|
and edition.game.name != edition.sort_name
|
||||||
|
else "(identical)"
|
||||||
|
),
|
||||||
|
truncate_with_popover(str(edition.platform)),
|
||||||
|
edition.year_released,
|
||||||
|
edition.wikidata,
|
||||||
|
local_strftime(edition.created_at, dateformat),
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse("edit_edition", args=[edition.pk]),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse(
|
||||||
|
"delete_edition", args=[edition.pk]
|
||||||
|
),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for edition in editions
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, "list_purchases.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def edit_edition(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
|
||||||
|
edition = get_object_or_404(Edition, id=edition_id)
|
||||||
|
form = EditionForm(request.POST or None, instance=edition)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("list_editions")
|
||||||
|
|
||||||
|
context: dict[str, Any] = {"form": form, "title": "Edit edition"}
|
||||||
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
|
||||||
|
edition = get_object_or_404(Edition, id=edition_id)
|
||||||
|
edition.delete()
|
||||||
|
return redirect("list_editions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||||
|
context: dict[str, Any] = {}
|
||||||
|
if request.method == "POST":
|
||||||
|
form = EditionForm(request.POST or None)
|
||||||
|
if form.is_valid():
|
||||||
|
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 = get_object_or_404(Game, 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["title"] = "Add New Edition"
|
||||||
|
context["script_name"] = "add_edition.js"
|
||||||
|
return render(request, "add_edition.html", context)
|
335
games/views/game.py
Normal file
335
games/views/game.py
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import Prefetch
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.time import (
|
||||||
|
dateformat,
|
||||||
|
durationformat,
|
||||||
|
durationformat_manual,
|
||||||
|
format_duration,
|
||||||
|
local_strftime,
|
||||||
|
timeformat,
|
||||||
|
)
|
||||||
|
from common.utils import A, Button, safe_division, truncate_with_popover
|
||||||
|
from games.forms import GameForm
|
||||||
|
from games.models import Edition, Game, Purchase, Session
|
||||||
|
from games.views.general import use_custom_redirect
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_games(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[Any, Any] = {}
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
limit = request.GET.get("limit", 10)
|
||||||
|
games = Game.objects.order_by("-created_at")
|
||||||
|
page_obj = None
|
||||||
|
if int(limit) != 0:
|
||||||
|
paginator = Paginator(games, limit)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
games = page_obj.object_list
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Manage games",
|
||||||
|
"page_obj": page_obj or None,
|
||||||
|
"elided_page_range": (
|
||||||
|
page_obj.paginator.get_elided_page_range(
|
||||||
|
page_number, on_each_side=1, on_ends=1
|
||||||
|
)
|
||||||
|
if page_obj
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"data": {
|
||||||
|
"header_action": A([], Button([], "Add game"), url="add_game"),
|
||||||
|
"columns": [
|
||||||
|
"Name",
|
||||||
|
"Sort Name",
|
||||||
|
"Year",
|
||||||
|
"Wikidata",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
A(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"href",
|
||||||
|
reverse(
|
||||||
|
"view_game",
|
||||||
|
args=[game.pk],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
truncate_with_popover(game.name),
|
||||||
|
),
|
||||||
|
truncate_with_popover(
|
||||||
|
game.sort_name
|
||||||
|
if game.sort_name is not None and game.name != game.sort_name
|
||||||
|
else "(identical)"
|
||||||
|
),
|
||||||
|
game.year_released,
|
||||||
|
game.wikidata,
|
||||||
|
local_strftime(game.created_at, dateformat),
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse("edit_game", args=[game.pk]),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse("delete_game", args=[game.pk]),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for game in games
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, "list_purchases.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_game(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[str, Any] = {}
|
||||||
|
form = GameForm(request.POST or None)
|
||||||
|
if form.is_valid():
|
||||||
|
game = form.save()
|
||||||
|
if "submit_and_redirect" in request.POST:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("add_edition_for_game", kwargs={"game_id": game.id})
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect("list_games")
|
||||||
|
|
||||||
|
context["form"] = form
|
||||||
|
context["title"] = "Add New Game"
|
||||||
|
context["script_name"] = "add_game.js"
|
||||||
|
return render(request, "add_game.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
|
game = get_object_or_404(Game, id=game_id)
|
||||||
|
game.delete()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
|
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
|
context = {}
|
||||||
|
purchase = get_object_or_404(Game, id=game_id)
|
||||||
|
form = GameForm(request.POST or None, instance=purchase)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
context["title"] = "Edit Game"
|
||||||
|
context["form"] = form
|
||||||
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
|
game = Game.objects.get(id=game_id)
|
||||||
|
nongame_related_purchases_prefetch: Prefetch[Purchase] = Prefetch(
|
||||||
|
"related_purchases",
|
||||||
|
queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by(
|
||||||
|
"date_purchased"
|
||||||
|
),
|
||||||
|
to_attr="nongame_related_purchases",
|
||||||
|
)
|
||||||
|
game_purchases_prefetch: Prefetch[Purchase] = 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")
|
||||||
|
)
|
||||||
|
|
||||||
|
purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased")
|
||||||
|
|
||||||
|
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 = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
|
||||||
|
latest_session = sessions.latest()
|
||||||
|
playrange_end = local_strftime(latest_session.timestamp_start, "%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")
|
||||||
|
)
|
||||||
|
|
||||||
|
edition_data: dict[str, Any] = {
|
||||||
|
"columns": [
|
||||||
|
"Name",
|
||||||
|
"Platform",
|
||||||
|
"Year Released",
|
||||||
|
"Actions",
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
edition.name,
|
||||||
|
edition.platform,
|
||||||
|
edition.year_released,
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse("edit_edition", args=[edition.pk]),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse("delete_edition", args=[edition.pk]),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for edition in editions
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
purchase_data: dict[str, Any] = {
|
||||||
|
"columns": ["Name", "Type", "Price", "Actions"],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
purchase.name if purchase.name else purchase.edition.name,
|
||||||
|
purchase.get_type_display(),
|
||||||
|
f"{purchase.price} {purchase.price_currency}",
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse("edit_purchase", args=[purchase.pk]),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse("delete_purchase", args=[purchase.pk]),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for purchase in purchases
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions_all = Session.objects.filter(purchase__edition__game=game).order_by(
|
||||||
|
"-timestamp_start"
|
||||||
|
)
|
||||||
|
session_count = sessions_all.count()
|
||||||
|
session_paginator = Paginator(sessions_all, 5)
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
session_page_obj = session_paginator.get_page(page_number)
|
||||||
|
sessions = session_page_obj.object_list
|
||||||
|
|
||||||
|
session_data: dict[str, Any] = {
|
||||||
|
"columns": ["Date", "Duration", "Duration (manual)", "Actions"],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
f"{local_strftime(session.timestamp_start)}{f" — {session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
|
||||||
|
(
|
||||||
|
format_duration(session.duration_calculated, durationformat)
|
||||||
|
if session.duration_calculated
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format_duration(session.duration_manual, durationformat_manual)
|
||||||
|
if session.duration_manual
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse("edit_session", args=[session.pk]),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse("delete_session", args=[session.pk]),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for session in sessions
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
context: dict[str, Any] = {
|
||||||
|
"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": sessions,
|
||||||
|
"title": f"Game Overview - {game.name}",
|
||||||
|
"hours_sum": total_hours,
|
||||||
|
"edition_data": edition_data,
|
||||||
|
"purchase_data": purchase_data,
|
||||||
|
"session_data": session_data,
|
||||||
|
"session_page_obj": session_page_obj,
|
||||||
|
"session_elided_page_range": (
|
||||||
|
session_page_obj.paginator.get_elided_page_range(
|
||||||
|
page_number, on_each_side=1, on_ends=1
|
||||||
|
)
|
||||||
|
if session_page_obj and session_count > 5
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
request.session["return_path"] = request.path
|
||||||
|
return render(request, "view_game.html", context)
|
504
games/views/general.py
Normal file
504
games/views/general.py
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields
|
||||||
|
from django.db.models.functions import TruncDate, TruncMonth
|
||||||
|
from django.db.models.manager import BaseManager
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.time import format_duration
|
||||||
|
from common.utils import safe_division
|
||||||
|
from games.models import Edition, Game, Platform, Purchase, Session
|
||||||
|
|
||||||
|
|
||||||
|
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||||
|
return {
|
||||||
|
"game_available": Game.objects.exists(),
|
||||||
|
"edition_available": Edition.objects.exists(),
|
||||||
|
"platform_available": Platform.objects.exists(),
|
||||||
|
"purchase_available": Purchase.objects.exists(),
|
||||||
|
"session_count": Session.objects.exists(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def use_custom_redirect(
|
||||||
|
func: Callable[..., HttpResponse],
|
||||||
|
) -> Callable[..., HttpResponse]:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||||
|
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: BaseManager[Purchase] = 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_date = "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: int = 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: HttpRequest, year: int = 0) -> HttpResponse:
|
||||||
|
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_date = "N/A"
|
||||||
|
last_play_date = "N/A"
|
||||||
|
first_play_game = None
|
||||||
|
last_play_game = None
|
||||||
|
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 index(request: HttpRequest) -> HttpResponse:
|
||||||
|
return redirect("list_sessions")
|
112
games/views/platform.py
Normal file
112
games/views/platform.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.time import dateformat, local_strftime
|
||||||
|
from common.utils import A, Button
|
||||||
|
from games.forms import PlatformForm
|
||||||
|
from games.models import Platform
|
||||||
|
from games.views.general import use_custom_redirect
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[Any, Any] = {}
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
limit = request.GET.get("limit", 10)
|
||||||
|
platforms = Platform.objects.order_by("-created_at")
|
||||||
|
page_obj = None
|
||||||
|
if int(limit) != 0:
|
||||||
|
paginator = Paginator(platforms, limit)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
platforms = page_obj.object_list
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Manage platforms",
|
||||||
|
"page_obj": page_obj or None,
|
||||||
|
"elided_page_range": (
|
||||||
|
page_obj.paginator.get_elided_page_range(
|
||||||
|
page_number, on_each_side=1, on_ends=1
|
||||||
|
)
|
||||||
|
if page_obj
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"data": {
|
||||||
|
"header_action": A([], Button([], "Add platform"), url="add_platform"),
|
||||||
|
"columns": [
|
||||||
|
"Name",
|
||||||
|
"Group",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
platform.name,
|
||||||
|
platform.group,
|
||||||
|
local_strftime(platform.created_at, dateformat),
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse(
|
||||||
|
"edit_platform", args=[platform.pk]
|
||||||
|
),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse(
|
||||||
|
"delete_platform", args=[platform.pk]
|
||||||
|
),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for platform in platforms
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, "list_purchases.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
||||||
|
platform = get_object_or_404(Platform, id=platform_id)
|
||||||
|
platform.delete()
|
||||||
|
return redirect("list_platforms")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
|
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
||||||
|
context = {}
|
||||||
|
platform = get_object_or_404(Platform, id=platform_id)
|
||||||
|
form = PlatformForm(request.POST or None, instance=platform)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("list_platforms")
|
||||||
|
context["title"] = "Edit Platform"
|
||||||
|
context["form"] = form
|
||||||
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_platform(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[str, Any] = {}
|
||||||
|
form = PlatformForm(request.POST or None)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("index")
|
||||||
|
|
||||||
|
context["form"] = form
|
||||||
|
context["title"] = "Add New Platform"
|
||||||
|
return render(request, "add.html", context)
|
197
games/views/purchase.py
Normal file
197
games/views/purchase.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.http import (
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseBadRequest,
|
||||||
|
HttpResponseRedirect,
|
||||||
|
)
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from common.time import dateformat, local_strftime
|
||||||
|
from common.utils import A, Button, truncate_with_popover
|
||||||
|
from games.forms import PurchaseForm
|
||||||
|
from games.models import Edition, Purchase
|
||||||
|
from games.views.general import use_custom_redirect
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[Any, Any] = {}
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
limit = request.GET.get("limit", 10)
|
||||||
|
purchases = Purchase.objects.order_by("-date_purchased")
|
||||||
|
page_obj = None
|
||||||
|
if int(limit) != 0:
|
||||||
|
paginator = Paginator(purchases, limit)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
purchases = page_obj.object_list
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Manage purchases",
|
||||||
|
"page_obj": page_obj or None,
|
||||||
|
"elided_page_range": (
|
||||||
|
page_obj.paginator.get_elided_page_range(
|
||||||
|
page_number, on_each_side=1, on_ends=1
|
||||||
|
)
|
||||||
|
if page_obj
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"data": {
|
||||||
|
"header_action": A([], Button([], "Add purchase"), url="add_purchase"),
|
||||||
|
"columns": [
|
||||||
|
"Name",
|
||||||
|
"Type",
|
||||||
|
"Platform",
|
||||||
|
"Price",
|
||||||
|
"Currency",
|
||||||
|
"Infinite",
|
||||||
|
"Purchased",
|
||||||
|
"Refunded",
|
||||||
|
"Finished",
|
||||||
|
"Dropped",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
A(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"href",
|
||||||
|
reverse(
|
||||||
|
"view_game",
|
||||||
|
args=[purchase.edition.game.pk],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
truncate_with_popover(
|
||||||
|
purchase.edition.game.name
|
||||||
|
if purchase.type == "game"
|
||||||
|
else f"{purchase.edition.game.name} ({purchase.name})"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
purchase.get_type_display(),
|
||||||
|
purchase.platform,
|
||||||
|
purchase.price,
|
||||||
|
purchase.price_currency,
|
||||||
|
purchase.infinite,
|
||||||
|
local_strftime(purchase.date_purchased, dateformat),
|
||||||
|
(
|
||||||
|
local_strftime(purchase.date_refunded, dateformat)
|
||||||
|
if purchase.date_refunded
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
local_strftime(purchase.date_finished, dateformat)
|
||||||
|
if purchase.date_finished
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
local_strftime(purchase.date_dropped, dateformat)
|
||||||
|
if purchase.date_dropped
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
local_strftime(purchase.created_at, dateformat),
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse(
|
||||||
|
"edit_purchase", args=[purchase.pk]
|
||||||
|
),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse(
|
||||||
|
"delete_purchase", args=[purchase.pk]
|
||||||
|
),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for purchase in purchases
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, "list_purchases.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
|
||||||
|
context: dict[str, Any] = {}
|
||||||
|
initial = {"date_purchased": timezone.now()}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = PurchaseForm(request.POST or None, initial=initial)
|
||||||
|
if form.is_valid():
|
||||||
|
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("list_purchases")
|
||||||
|
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["title"] = "Add New Purchase"
|
||||||
|
context["script_name"] = "add_purchase.js"
|
||||||
|
return render(request, "add_purchase.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
|
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
|
context = {}
|
||||||
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
|
form = PurchaseForm(request.POST or None, instance=purchase)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
context["title"] = "Edit Purchase"
|
||||||
|
context["form"] = form
|
||||||
|
context["purchase_id"] = str(purchase_id)
|
||||||
|
context["script_name"] = "add_purchase.js"
|
||||||
|
return render(request, "add_purchase.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
|
purchase.delete()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
|
||||||
|
edition_id = request.GET.get("edition")
|
||||||
|
if not edition_id:
|
||||||
|
return HttpResponseBadRequest("Invalid edition_id")
|
||||||
|
form = PurchaseForm()
|
||||||
|
form.fields["related_purchase"].queryset = Purchase.objects.filter(
|
||||||
|
edition_id=edition_id, type=Purchase.GAME
|
||||||
|
).order_by("edition__sort_name")
|
||||||
|
return render(request, "partials/related_purchase_field.html", {"form": form})
|
199
games/views/session.py
Normal file
199
games/views/session.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from common.time import (
|
||||||
|
dateformat,
|
||||||
|
durationformat,
|
||||||
|
durationformat_manual,
|
||||||
|
format_duration,
|
||||||
|
local_strftime,
|
||||||
|
timeformat,
|
||||||
|
)
|
||||||
|
from common.utils import A, Button, truncate_with_popover
|
||||||
|
from games.forms import SessionForm
|
||||||
|
from games.models import Purchase, Session
|
||||||
|
from games.views.general import use_custom_redirect
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_sessions(request: HttpRequest) -> HttpResponse:
|
||||||
|
context: dict[Any, Any] = {}
|
||||||
|
page_number = request.GET.get("page", 1)
|
||||||
|
limit = request.GET.get("limit", 10)
|
||||||
|
sessions = Session.objects.order_by("-timestamp_start")
|
||||||
|
page_obj = None
|
||||||
|
if int(limit) != 0:
|
||||||
|
paginator = Paginator(sessions, limit)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
sessions = page_obj.object_list
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Manage sessions",
|
||||||
|
"page_obj": page_obj or None,
|
||||||
|
"elided_page_range": (
|
||||||
|
page_obj.paginator.get_elided_page_range(
|
||||||
|
page_number, on_each_side=1, on_ends=1
|
||||||
|
)
|
||||||
|
if page_obj
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"data": {
|
||||||
|
"header_action": A([], Button([], "Add session"), url="add_session"),
|
||||||
|
"columns": [
|
||||||
|
"Name",
|
||||||
|
"Date",
|
||||||
|
"Duration",
|
||||||
|
"Duration (manual)",
|
||||||
|
"Device",
|
||||||
|
"Created",
|
||||||
|
"Actions",
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
A(
|
||||||
|
children=truncate_with_popover(session.purchase.edition.name),
|
||||||
|
url=reverse(
|
||||||
|
"view_game",
|
||||||
|
args=[session.purchase.edition.game.pk],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
|
||||||
|
(
|
||||||
|
format_duration(session.duration_calculated, durationformat)
|
||||||
|
if session.duration_calculated
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format_duration(session.duration_manual, durationformat_manual)
|
||||||
|
if session.duration_manual
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
session.device,
|
||||||
|
session.created_at.strftime(dateformat),
|
||||||
|
render_to_string(
|
||||||
|
"cotton/button_group_sm.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": reverse("edit_session", args=[session.pk]),
|
||||||
|
"text": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse(
|
||||||
|
"delete_session", args=[session.pk]
|
||||||
|
),
|
||||||
|
"text": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for session in sessions
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return render(request, "list_purchases.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
|
||||||
|
context = {}
|
||||||
|
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
|
||||||
|
|
||||||
|
last = Session.objects.last()
|
||||||
|
if last != None:
|
||||||
|
initial["purchase"] = last.purchase
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = SessionForm(request.POST or None, initial=initial)
|
||||||
|
if form.is_valid():
|
||||||
|
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["form"] = form
|
||||||
|
return render(request, "add_session.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
|
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||||
|
context = {}
|
||||||
|
session = get_object_or_404(Session, id=session_id)
|
||||||
|
form = SessionForm(request.POST or None, instance=session)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
context["title"] = "Edit Session"
|
||||||
|
context["form"] = form
|
||||||
|
return render(request, "add_session.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
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: HttpRequest, session_id: int, template: str = ""
|
||||||
|
) -> HttpResponse:
|
||||||
|
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)
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@use_custom_redirect
|
||||||
|
def end_session(
|
||||||
|
request: HttpRequest, session_id: int, template: str = ""
|
||||||
|
) -> HttpResponse:
|
||||||
|
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: HttpRequest, session_id: int = 0) -> HttpResponse:
|
||||||
|
session = get_object_or_404(Session, id=session_id)
|
||||||
|
session.delete()
|
||||||
|
return redirect("list_sessions")
|
@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"npm-check-updates": "^16.14.20",
|
||||||
|
"tailwindcss": "^3.4.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"flowbite": "^2.4.1"
|
||||||
|
}
|
||||||
|
}
|
1039
poetry.lock
generated
1039
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user