Compare commits
454 Commits
1.0.3
...
36411c99a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
36411c99a7
|
|||
| 360e8f9eaf | |||
|
c10b7a8013
|
|||
|
103c29e234
|
|||
|
5003b739d3
|
|||
|
4ba3ed555f
|
|||
|
e3b53cd4a9
|
|||
|
a4e697a274
|
|||
|
b8187c32b1
|
|||
|
bf2b86ba1f
|
|||
|
913c7d3a98
|
|||
|
37e3c69abc
|
|||
|
0866eb25e9
|
|||
|
39f21bc7db
|
|||
|
1416d00a37
|
|||
|
d9fe99963a
|
|||
|
393476be85
|
|||
|
e32af2f576
|
|||
|
e565002244
|
|||
|
1a4e51c95a
|
|||
|
eae020fd34
|
|||
|
1f4dd60c54
|
|||
|
656a96f55c
|
|||
|
8c3e819a5f
|
|||
|
ff11e35115
|
|||
|
ebef0bba87
|
|||
|
140f3d2bd6
|
|||
|
245a4f5b3e
|
|||
|
cd9f0b4111
|
|||
|
f82c61ef1e
|
|||
|
4e3b0ddb08
|
|||
|
a549050860
|
|||
|
596d1ccfe1
|
|||
|
bb26fec5e3
|
|||
|
1ba7de0bb7
|
|||
|
3391fb72f2
|
|||
|
0986e59fe7
|
|||
|
46b1199863
|
|||
|
bc1092b0b3
|
|||
|
996c0107c9
|
|||
|
277ecd1b55
|
|||
|
4e3a5ef682
|
|||
|
233f63f18e
|
|||
|
016f307240
|
|||
|
715acd6244
|
|||
|
0bc48d01a7
|
|||
|
c5646d0451
|
|||
|
710a0fc5bc
|
|||
|
1d0d16b4d4
|
|||
|
6b89bab0a6
|
|||
|
2bc2d98f88
|
|||
|
06096d471e
|
|||
|
40869e25f3
|
|||
|
4f0ac21ba3
|
|||
|
3801949fdb
|
|||
|
f895dc1265
|
|||
|
04601ca13d
|
|||
|
d53575ab48
|
|||
|
4e1f55855d
|
|||
|
95af4ceed6
|
|||
|
6bb89438df
|
|||
|
bd5525e57e
|
|||
|
5cac19be7b
|
|||
|
a6577a9e53
|
|||
|
243830a84a
|
|||
|
7032b8c7c7
|
|||
|
5cc1652002
|
|||
|
7cf2180192
|
|||
|
ad0641f95b
|
|||
|
abdcfdfe64
|
|||
|
31daf2efe0
|
|||
|
6d53fca910
|
|||
|
f7e426e030
|
|||
|
b29e4edd72
|
|||
|
3c58851b88
|
|||
|
99f3540825
|
|||
|
5e778bec30
|
|||
|
fea9d9784d
|
|||
|
23b4a7a069
|
|||
|
89de85c00d
|
|||
|
d892659132
|
|||
|
341e62283b
|
|||
|
61b6c1c55f
|
|||
|
eeaa02bada
|
|||
|
9d16bc2546
|
|||
|
7a52b59b3d
|
|||
|
0ce59a8cc6
|
|||
|
e0dfc0fc3e
|
|||
|
8cb67ca002
|
|||
|
be2a01840c
|
|||
|
612c42ebb7
|
|||
|
e2255a1c85
|
|||
|
0b274b4403
|
|||
|
ddd75f22b0
|
|||
|
843eed64d6
|
|||
|
50e7efcfae
|
|||
|
3e713a7637
|
|||
|
2d7342c0d5
|
|||
|
aba9bc994d
|
|||
|
967ff7df07
|
|||
|
2ab497fd54
|
|||
|
34148466c7
|
|||
|
b22e185d47
|
|||
|
b2b69339b3
|
|||
|
89d1bbdd9e
|
|||
|
637e3e6493
|
|||
|
d213a3d35d
|
|||
|
2f4e16dd54
|
|||
|
6f62889e92
|
|||
|
4ec808eeec
|
|||
|
69d27958f3
|
|||
|
4ec1cf5f28
|
|||
|
d936fdc60d
|
|||
|
2116cfc219
|
|||
|
6bd8271291
|
|||
|
e571feadef
|
|||
|
23c1ce1f96
|
|||
|
33103daebc
|
|||
|
ba6028e43d
|
|||
|
c2853a3ecc
|
|||
|
cd90d60475
|
|||
|
11cea2142a
|
|||
|
24578b64fe
|
|||
|
13e607f9a7
|
|||
|
fc0d8db8e8
|
|||
|
8acc4f9c5b
|
|||
|
6b7a96dc06
|
|||
|
5c5fd5f26a
|
|||
|
7181b6472c
|
|||
|
af06d07ee3
|
|||
|
315e22a8ac
|
|||
| 19676f8441 | |||
| f61cde180f | |||
| a53818257c | |||
| 2d3ea714c4 | |||
| 832bb48983 | |||
| c6b1badf39 | |||
| a3ed93c154 | |||
| cf503a7b7d | |||
| d81df6452a | |||
|
d9290373b0
|
|||
|
f8d621e710
|
|||
|
9992d9c9bd
|
|||
|
2ae81bb00f
|
|||
|
993abb4710
|
|||
|
23502eab85
|
|||
|
c517d735c7
|
|||
|
19056f846e
|
|||
|
0759ad0804
|
|||
|
228fc2bf5f
|
|||
|
a5a7041920
|
|||
|
fbd829f70e
|
|||
|
4873f25248
|
|||
|
3578f1707f
|
|||
|
b74ccb6eaa
|
|||
|
b0b1bb2d42
|
|||
|
c40764a02f
|
|||
|
649351efde
|
|||
|
698c8966c0
|
|||
|
7f6584ecf7
|
|||
|
540f5ee42c
|
|||
|
1c73268258
|
|||
|
3063a3d143
|
|||
|
b589199ca6
|
|||
|
2fc661dade
|
|||
|
1f535a6e84
|
|||
|
a9c1135639
|
|||
|
58cfaca1a9
|
|||
|
c1b3493c80
|
|||
|
a1df8720f5
|
|||
|
5a852bc2b9
|
|||
|
8ab9bfeeeb
|
|||
|
5eee7176d4
|
|||
|
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
|
|||
| c35b539c42 | |||
| bbe5e072b2 | |||
| 6fc2f623dc | |||
| 9481bd5fef | |||
| 4083165123 | |||
| 45bb2681c7 | |||
| dbb8ec3f9a | |||
| 206b5f6d46 | |||
| b7e14ecc83 | |||
| 912e010729 | |||
| a485237456 | |||
| f5faf92ee0 | |||
| 07452d8c43 | |||
| 229a79d266 | |||
| c6ed577fe3 | |||
| 171e4779a3 | |||
| 79f94e5984 | |||
| ccebcb89c6 | |||
| fe0a6b39e3 | |||
| 6a495f951f | |||
| c8646d0a0c | |||
| f2bb15e669 | |||
| c49177d63c | |||
| bd8d30eac1 | |||
| c44d8bf427 | |||
| 3f037b4c7c | |||
| 8783d1fc8e | |||
| 9a1d24dbfd | |||
| 4720660cff | |||
| e158bc0623 | |||
| 8982fc5086 | |||
| 729e1d939b | |||
| 2b4683e489 | |||
| cce810e8cf | |||
| 62cd17f702 | |||
| f31280c682 | |||
| a745d16ec3 | |||
| ae079e36ec | |||
| c8a3212b77 | |||
| d211326c3f | |||
| 270a291f05 | |||
| 13b750ca92 | |||
| 015b6db2f7 | |||
| 667b161fff | |||
| 5958cbf4a6 | |||
| 3b37f2c3f0 | |||
| 4517ff2b5a | |||
| 884ce13e26 | |||
| dd219bae9d | |||
| 60d29090a1 | |||
| 1bc3ca057b | |||
| c2c0886451 | |||
| b0be7b5887 | |||
| 099d989f16 | |||
| a879360ebd | |||
| 866f2526e6 | |||
| ce3c4b55f0 | |||
| c52cd822ae | |||
| cdc6ca1324 | |||
| e7ed349356 | |||
| 5052ca7dbf | |||
| f408bfd927 | |||
| 666dee33ba | |||
| e0b09e051a | |||
| 4552cf7616 | |||
| a614b51d29 | |||
| e67aa3fda1 | |||
| 8423fd02b4 | |||
| 2bd07e5f2d | |||
| 058b83522c | |||
| f13ed8a078 | |||
| 02d5adcb3c | |||
| d6fb16bb74 | |||
| 71b90b8202 | |||
| 3ee36932c3 | |||
| 391fcc79a8 | |||
| 57d4fd7212 | |||
| a5b2854bf6 | |||
| 518c0ecd56 | |||
| a6cd7a3430 | |||
| dba8414fd9 | |||
| 0e2113eefd | |||
| c4b0347f3b | |||
| c6ed21167c | |||
| 4ce15c44fc | |||
| c814b4c2cb | |||
| 11b9c602de | |||
| 9a332593f4 | |||
| 22935721ca | |||
| a2ecdcf44a | |||
| 3c958c4a13 | |||
| 3db1724e22 | |||
| d2a9630b04 | |||
| e3ee832d3f | |||
| 7467e2732d | |||
| 787ee8640f | |||
| ab41222f3c | |||
| 29bf3b1946 | |||
| 3f7ccea2e2 | |||
| b5ffb3586b | |||
| 26d57a238e | |||
| 2d5ad3182c | |||
| 49cc3ea0cc | |||
| 440e1cfb71 | |||
| 1cbd8c5c55 | |||
| bc81a0ee8e | |||
| c5653977ff | |||
| f151730ab6 | |||
| f469a67d94 | |||
| 104ffc9d03 | |||
| a4b13eb247 | |||
| 2307fac83a | |||
| 6b52c0d4c4 | |||
| ff5d8c215d | |||
| cdb3b89b08 | |||
| ffa8198540 | |||
| 0b7da3550c | |||
| e1655d6cfa | |||
| 29c41865d0 | |||
| d21b461726 | |||
| 95489cfb78 | |||
| fa4f1c4810 | |||
| 366c25a1ff | |||
| a3042caa20 | |||
| 7997f9bbb2 | |||
|
b78c4ba9c5
|
|||
|
1df889c45d
|
|||
|
468d05a9e2
|
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Django Time Tracker",
|
||||
"dockerFile": "../devcontainer.Dockerfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"python.pythonPath": "/usr/local/bin/python",
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"terminal.integrated.defaultProfile.linux": "bash"
|
||||
},
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"batisteo.vscode-django",
|
||||
"charliermarsh.ruff",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
},
|
||||
"forwardPorts": [8000],
|
||||
"postCreateCommand": "poetry install && poetry run python manage.py migrate && npm install && make dev",
|
||||
}
|
||||
+9
-1
@@ -5,4 +5,12 @@
|
||||
.venv
|
||||
.vscode
|
||||
node_modules
|
||||
src/timetracker/static/*
|
||||
static
|
||||
.drone.yml
|
||||
.editorconfig
|
||||
.gitignore
|
||||
CHANGELOG.md
|
||||
db.sqlite3
|
||||
docker-compose*
|
||||
Dockerfile
|
||||
Makefile
|
||||
|
||||
+19
-3
@@ -5,23 +5,28 @@ name: default
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: python:3.10
|
||||
image: python:3.12
|
||||
commands:
|
||||
- python -m pip install poetry
|
||||
- poetry install
|
||||
- poetry env info
|
||||
- poetry run python manage.py migrate
|
||||
- poetry run pytest
|
||||
- name: build container (prod)
|
||||
|
||||
- name: build-prod
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.kucharczyk.xyz/timetracker
|
||||
tags:
|
||||
- latest
|
||||
- 1.1.0
|
||||
depends_on:
|
||||
- "test"
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
|
||||
- name: build container (non-prod)
|
||||
- name: build-non-prod
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.kucharczyk.xyz/timetracker
|
||||
@@ -32,6 +37,17 @@ steps:
|
||||
branch:
|
||||
exclude:
|
||||
- main
|
||||
depends_on:
|
||||
- "test"
|
||||
|
||||
- name: redeploy on portainer
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls:
|
||||
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
|
||||
depends_on:
|
||||
- "build-prod"
|
||||
|
||||
|
||||
|
||||
trigger:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,py}]
|
||||
charset = utf-8
|
||||
|
||||
# 4 space indentation
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[**/*.js]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.html]
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1,21 @@
|
||||
# Docker registry URL (used in docker-compose.yml)
|
||||
REGISTRY_URL=registry.kucharczyk.xyz
|
||||
|
||||
# Container timezone
|
||||
TZ=Europe/Prague
|
||||
|
||||
# User/group IDs for container (used in entrypoint.sh)
|
||||
PUID=1000
|
||||
PGID=100
|
||||
|
||||
# External port mapping
|
||||
TIMETRACKER_EXTERNAL_PORT=8000
|
||||
|
||||
# Django production mode (set to "1" for production)
|
||||
PROD=1
|
||||
|
||||
# Database directory (defaults to project root)
|
||||
DATA_DIR=/home/timetracker/app/data
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Django CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore: [ 'README.md' ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run Migrations
|
||||
run: uv run python manage.py migrate
|
||||
|
||||
- name: Run Tests
|
||||
run: uv run --with pytest-django pytest
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set Version
|
||||
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
|
||||
|
||||
- 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 }}
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
+6
-3
@@ -1,10 +1,13 @@
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.venv
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
db.sqlite3
|
||||
static/admin/
|
||||
static/django_extensions/
|
||||
data/
|
||||
/static/
|
||||
dist/
|
||||
.DS_Store
|
||||
.python-version
|
||||
.direnv
|
||||
|
||||
Vendored
+11
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: Django",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"runserver"
|
||||
],
|
||||
"django": true,
|
||||
"autoStartBrowser": false,
|
||||
"program": "${workspaceFolder}/manage.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+26
-1
@@ -4,5 +4,30 @@
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
"python.analysis.typeCheckingMode": "strict",
|
||||
"[python]": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
+208
@@ -1,3 +1,211 @@
|
||||
## 1.7.0 / 2026-05-12
|
||||
|
||||
### New
|
||||
* Add toast notification system with HTMX middleware integration
|
||||
* Add component system (Cotton-based): button, modal, table_row, search_field, gamelink
|
||||
* Add needs_price_update field to Purchase model for reliable price change detection
|
||||
* Add confirmation dialog before deleting a game
|
||||
* Add game status information documentation (STATUSES.md)
|
||||
* Allow directly updating device in session list via inline selector
|
||||
* Migrate from Poetry to uv for Python dependency management
|
||||
* Scope URLs to the games namespace
|
||||
* Start session template shared between add and edit views
|
||||
|
||||
### Improved
|
||||
* Major style overhaul: CSS variables, improved dark mode, Flowbite 4.x upgrade
|
||||
* Improve game status evaluation and add abandon prompt on refund
|
||||
* Robustify Docker container and fix default database location
|
||||
* Make component rendering deterministic for improved caching
|
||||
* Component caching: deterministic randomid generation
|
||||
* Component test suite with 1000+ lines of tests
|
||||
* Make tests more robust with django-pytest
|
||||
* Update NameWithIcon component: testable, fixed platform extraction bug
|
||||
* Pin Caddy version and improve make dev-prod
|
||||
* Add .env.example documenting environment variables
|
||||
* Unify A() component with explicit url_name vs href parameters
|
||||
|
||||
### Fixed
|
||||
* Fix refund confirmation not working
|
||||
* Fix stats view missing first and last game values
|
||||
* Fix A() component silent fallback on URL typos
|
||||
* Fix secondary submit buttons not working
|
||||
* Fix button not passing attributes
|
||||
* Fix default mutable arguments in component functions
|
||||
* Fix extra submit button when adding purchase
|
||||
* Fix pointer cursor on search field button
|
||||
|
||||
### Removed
|
||||
* Remove GraphQL API
|
||||
|
||||
### Dependencies
|
||||
* Update django-ninja to 1.6.2
|
||||
|
||||
## 1.6.1 / 2026-01-30 11:48+01:00
|
||||
|
||||
### New
|
||||
* Pre-fill time played into new playevent, also tracks time since last playevent
|
||||
* Improve light theme and fix light/dark theme switcher
|
||||
* Fix purchase form logic
|
||||
* Update dependencies
|
||||
|
||||
## 1.6.0 / 2025-01-15 23:13+01:00
|
||||
|
||||
### New
|
||||
* Visual overhaul of many pages
|
||||
* Render notes as Markdown
|
||||
* Require login by default
|
||||
* Add stats for dropped purchases, monthly playtimes
|
||||
* Allow deleting purchases
|
||||
* Add all-time stats
|
||||
* Manage purchases
|
||||
* Automatically convert purchase prices
|
||||
* Add emulated property to sessions
|
||||
* Add today's and last 7 days playtime stats to navbar
|
||||
|
||||
### Improved
|
||||
* mark refunded purchases red on game overview
|
||||
* increase session count on game overview when starting a new session
|
||||
* game overview:
|
||||
* sort purchases also by date purchased (on top of date released)
|
||||
* improve header format, make it more appealing
|
||||
* ignore manual sessions when calculating session average
|
||||
* stats: improve purchase name consistency
|
||||
* session list: use display name instead of sort name
|
||||
* unify the appearance of game links, and make them expand to full size on hover
|
||||
|
||||
### Fixed
|
||||
* Fix title not being displayed on the Recent sessions page
|
||||
* Avoid errors when displaying game overview with zero sessions
|
||||
|
||||
## 1.5.2 / 2024-01-14 21:27+01:00
|
||||
|
||||
## Improved
|
||||
* game overview:
|
||||
* improve how editions and purchases are displayed
|
||||
* make it possible to end session from overview
|
||||
* add purchase: only allow choosing purchases of selected edition
|
||||
* session list:
|
||||
* starting and ending sessions is much faster/doest not reload the page
|
||||
* listing sessions is much faster
|
||||
|
||||
## 1.5.1 / 2023-11-14 21:10+01:00
|
||||
|
||||
## Improved
|
||||
* Disallow choosing non-game purchase as related purchase
|
||||
* Improve display of purchases
|
||||
|
||||
## 1.5.0 / 2023-11-14 19:27+01:00
|
||||
|
||||
## New
|
||||
* Add stat for finished this year's games
|
||||
* Add purchase types:
|
||||
* Game (previously all of them were this type)
|
||||
* DLC
|
||||
* Season Pass
|
||||
* Battle Pass
|
||||
|
||||
## Fixed
|
||||
* Order purchases by date on game view
|
||||
|
||||
## 1.4.0 / 2023-11-09 21:01+01:00
|
||||
|
||||
### New
|
||||
* More fields are now optional. This is to make it easier to add new items in bulk.
|
||||
* Game: Wikidata ID
|
||||
* Edition: Platform, Year
|
||||
* Purchase: Platform
|
||||
* Platform: Group
|
||||
* Session: Device
|
||||
* New fields:
|
||||
* Game: Year Released
|
||||
* To record original year of release
|
||||
* Upon migration, this will be set to a year of any of the game's edition that has it set
|
||||
* Purchase: Date Finished
|
||||
* Editions are now unique combination of name and platform
|
||||
* Add more stats:
|
||||
* All finished games
|
||||
* All finished 2023 games
|
||||
* All finished games that were purchased this year
|
||||
* Sessions (count)
|
||||
* Days played
|
||||
* Finished (count)
|
||||
* Unfinished (count)
|
||||
* Refunded (count)
|
||||
* Backlog Decrease (count)
|
||||
* New workflow:
|
||||
* Adding Game, Edition, Purchase, and Session in a row is now much faster
|
||||
|
||||
### Improved
|
||||
* game overview: simplify playtime range display
|
||||
* new session: order devices alphabetically
|
||||
* ignore English articles when sorting names
|
||||
* added a new sort_name field that gets automatically created
|
||||
* automatically fill certain values in forms:
|
||||
* new game: name and sort name after typing
|
||||
* new edition: name, sort name, and year when selecting game
|
||||
* new purchase: platform when selecting edition
|
||||
|
||||
## 1.3.0 / 2023-11-05 15:09+01:00
|
||||
|
||||
### New
|
||||
* Add Stats to the main navigation
|
||||
* Allow selecting year on the Stats page
|
||||
|
||||
### Improved
|
||||
* Make some pages redirect back instead to session list
|
||||
|
||||
### Improved
|
||||
* Make navigation more compact
|
||||
|
||||
### Fixed
|
||||
* Correctly limit sessions to a single year for stats
|
||||
|
||||
## 1.2.0 / 2023-11-01 20:18+01:00
|
||||
|
||||
### New
|
||||
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
|
||||
|
||||
### Enhancements
|
||||
* Add a button to start session from game overview
|
||||
|
||||
## 1.1.2 / 2023-10-13 16:30+02:00
|
||||
|
||||
### Enhancements
|
||||
* Durations are formatted in a consisent manner across all pages
|
||||
|
||||
### Fixes
|
||||
* Game Overview: display duration when >1 hour instead of displaying 0
|
||||
|
||||
## 1.1.1 / 2023-10-09 20:52+02:00
|
||||
|
||||
### New
|
||||
* Add notes section to game overview
|
||||
|
||||
### Enhancements
|
||||
* Make it possible to add any data on the game overview page
|
||||
|
||||
## 1.1.0 / 2023-10-09 00:01+02:00
|
||||
|
||||
### New
|
||||
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
|
||||
* Add helper buttons next to datime fields
|
||||
* Add copy button on Add session page to copy times between fields
|
||||
* Change fonts to IBM Plex
|
||||
|
||||
### Enhancements
|
||||
* Improve form appearance
|
||||
* Focus important fields on forms
|
||||
* Use the same form when editing a session as when adding a session
|
||||
* Change recent session view to current year instead of last 30 days
|
||||
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
|
||||
* Improve session listing (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||
|
||||
### Fixes
|
||||
|
||||
* Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58)
|
||||
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
|
||||
|
||||
|
||||
## 1.0.3 / 2023-02-20 17:16+01:00
|
||||
|
||||
* Add wikidata ID and year for editions
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
handle_path /static/* {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
reverse_proxy backend:8001
|
||||
}
|
||||
handle_path /static/* {
|
||||
root * /home/timetracker/app/static
|
||||
file_server
|
||||
}
|
||||
handle /robots.txt {
|
||||
root * /home/timetracker/app/games/static
|
||||
file_server
|
||||
}
|
||||
reverse_proxy localhost:8001
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
handle_path /static/* {
|
||||
root * static
|
||||
file_server browse
|
||||
}
|
||||
handle /robots.txt {
|
||||
root * games/static
|
||||
file_server browse
|
||||
}
|
||||
reverse_proxy :8001
|
||||
}
|
||||
+48
-27
@@ -1,34 +1,55 @@
|
||||
FROM node as css
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN npm install && \
|
||||
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
|
||||
|
||||
FROM python:3.10.9-slim-bullseye
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
ENV VERSION_NUMBER 1.0.3
|
||||
ENV PROD 1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt update && \
|
||||
apt install -y \
|
||||
bash \
|
||||
vim \
|
||||
curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m --uid 1000 timetracker
|
||||
WORKDIR /home/timetracker/app
|
||||
COPY . /home/timetracker/app/
|
||||
RUN chown -R timetracker:timetracker /home/timetracker/app
|
||||
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
|
||||
COPY entrypoint.sh /
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
|
||||
FROM python:3.14-slim-bookworm
|
||||
|
||||
ENV PROD=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/home/timetracker/app/.venv/bin:$PATH"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libcap2-bin \
|
||||
supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -m --uid 1000 timetracker \
|
||||
&& mkdir -p /var/log/supervisor /etc/supervisor/conf.d /home/timetracker/data \
|
||||
&& chown timetracker:timetracker /var/log/supervisor /home/timetracker/data
|
||||
|
||||
ARG CADDY_VERSION=2.9.1
|
||||
RUN curl -sL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \
|
||||
-o /tmp/caddy.tar.gz && \
|
||||
tar -xzf /tmp/caddy.tar.gz -C /tmp && \
|
||||
mv /tmp/caddy /usr/local/bin/caddy && \
|
||||
rm /tmp/caddy.tar.gz && \
|
||||
chmod +x /usr/local/bin/caddy
|
||||
|
||||
WORKDIR /home/timetracker/app
|
||||
|
||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||
|
||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
USER timetracker
|
||||
ENV PATH="$PATH:/home/timetracker/.local/bin"
|
||||
RUN pip install --no-cache-dir poetry
|
||||
RUN poetry install --without dev
|
||||
ENV VERSION_NUMBER=1.7.0
|
||||
|
||||
EXPOSE 8000
|
||||
CMD [ "/entrypoint.sh" ]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -3,60 +3,78 @@ all: css migrate
|
||||
initialize: npm css migrate sethookdir loadplatforms
|
||||
|
||||
HTMLFILES := $(shell find games/templates -type f)
|
||||
PYTHON_VERSION = 3.12
|
||||
|
||||
npm:
|
||||
npm install
|
||||
|
||||
css: common/input.css
|
||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
css-dev: css
|
||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
|
||||
npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
makemigrations:
|
||||
poetry run python manage.py makemigrations
|
||||
uv run python manage.py makemigrations
|
||||
|
||||
migrate: makemigrations
|
||||
poetry run python manage.py migrate
|
||||
uv run python manage.py migrate
|
||||
|
||||
init:
|
||||
uv install $(PYTHON_VERSION)
|
||||
uv sync
|
||||
npm install
|
||||
$(MAKE) sethookdir
|
||||
$(MAKE) loadplatforms
|
||||
|
||||
sethookdir:
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/*
|
||||
|
||||
dev:
|
||||
@npx concurrently \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
|
||||
dev: migrate
|
||||
poetry run python manage.py runserver
|
||||
|
||||
caddy:
|
||||
caddy run --watch
|
||||
|
||||
dev-prod: migrate collectstatic
|
||||
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
|
||||
@npx concurrently \
|
||||
--names "Caddy,Django,Django-Q" \
|
||||
"caddy run --config Caddyfile.dev" \
|
||||
"PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker" \
|
||||
"PROD=1 uv run manage.py qcluster"
|
||||
|
||||
dumpgames:
|
||||
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||
|
||||
loadplatforms:
|
||||
poetry run python manage.py loaddata platforms.yaml
|
||||
uv run python manage.py loaddata platforms.yaml
|
||||
|
||||
loadall:
|
||||
poetry run python manage.py loaddata data.yaml
|
||||
uv run python manage.py loaddata data.yaml
|
||||
|
||||
loadsample:
|
||||
poetry run python manage.py loaddata sample.yaml
|
||||
uv run python manage.py loaddata sample.yaml
|
||||
|
||||
createsuperuser:
|
||||
poetry run python manage.py createsuperuser
|
||||
uv run python manage.py createsuperuser
|
||||
|
||||
shell:
|
||||
poetry run python manage.py shell
|
||||
uv run python manage.py shell
|
||||
|
||||
collectstatic:
|
||||
poetry run python manage.py collectstatic --clear --no-input
|
||||
uv run python manage.py collectstatic --clear --no-input
|
||||
|
||||
poetry.lock: pyproject.toml
|
||||
poetry install
|
||||
uv.lock: pyproject.toml
|
||||
uv sync
|
||||
|
||||
test: poetry.lock
|
||||
poetry run pytest
|
||||
test: uv.lock
|
||||
uv run --with pytest-django pytest
|
||||
|
||||
date:
|
||||
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
cleanstatic:
|
||||
rm -r static/*
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# Timetracker
|
||||
|
||||
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`.
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# Game & Purchase Status Definitions
|
||||
|
||||
## Game Statuses
|
||||
|
||||
Games have a `status` field with the following values:
|
||||
|
||||
| Status | Code | Description |
|
||||
|--------|------|-------------|
|
||||
| **Unplayed** | `u` | Game was purchased but never played |
|
||||
| **Played** | `p` | Game was played but not yet finished |
|
||||
| **Finished** | `f` | Game has been completed |
|
||||
| **Retired** | `r` | Game was intentionally retired (e.g., no longer accessible, collector's item) |
|
||||
| **Abandoned** | `a` | Game was played but the user gave up on it |
|
||||
|
||||
**Setting game status:**
|
||||
- Users explicitly set game status via the UI (finish/drop purchase buttons, status change form)
|
||||
- Status changes are tracked in `GameStatusChange` model
|
||||
- Refunding a purchase always marks its games as abandoned
|
||||
|
||||
---
|
||||
|
||||
## Purchase-Level Status Concepts
|
||||
|
||||
These concepts determine whether a purchase appears in the "unfinished" or "dropped" lists in stats views.
|
||||
|
||||
### Finished
|
||||
|
||||
A purchase is considered **finished** when:
|
||||
|
||||
```
|
||||
Game.status == "f" OR Purchase.games.* has a PlayEvent with an ended date
|
||||
```
|
||||
|
||||
Either signal indicates the game is complete:
|
||||
- **Explicit**: User marked the game as finished (`Game.status = "f"`)
|
||||
- **Implicit**: A PlayEvent exists with `ended` date set (data-driven)
|
||||
|
||||
This uses **OR** logic during a transition period. Later, these signals should be kept in sync so only one source of truth is needed.
|
||||
|
||||
### Dropped
|
||||
|
||||
A purchase is considered **dropped** when:
|
||||
|
||||
```
|
||||
Game.status == "a" OR Purchase.date_refunded IS NOT NULL
|
||||
```
|
||||
|
||||
Either signal indicates the user no longer has an active interest in the game:
|
||||
- **Explicit**: User marked the game as abandoned (`Game.status = "a"`)
|
||||
- **Implicit**: User refunded the purchase (which automatically sets games to abandoned)
|
||||
|
||||
Note: Refunding a purchase always marks its games as abandoned. There is no option to refund without abandoning.
|
||||
|
||||
---
|
||||
|
||||
## Unfinished vs. Dropped
|
||||
|
||||
The stats views categorize purchases into **unfinished** and **dropped** lists.
|
||||
|
||||
### Unfinished
|
||||
|
||||
A purchase is **unfinished** when:
|
||||
1. It was purchased in the relevant time period (this year for yearly stats, all time for all-time stats)
|
||||
2. It was NOT refunded (only counts toward unfinished/backlog)
|
||||
3. It is NOT finished (per the finished definition above)
|
||||
4. It is NOT dropped (per the dropped definition above)
|
||||
5. It is NOT infinite (subscription, etc.)
|
||||
6. It IS a game or DLC (not season passes or battle passes)
|
||||
|
||||
**Unfinished = Active backlog** — games the user may still play.
|
||||
|
||||
### Dropped
|
||||
|
||||
A purchase is **dropped** when:
|
||||
1. It was purchased in the relevant time period
|
||||
2. It is NOT finished (per the finished definition above)
|
||||
3. It matches at least one dropped signal (per the dropped definition above)
|
||||
4. It is NOT infinite
|
||||
5. It IS a game or DLC
|
||||
|
||||
**Dropped = Terminal state** — games the user has given up on or refunded.
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Category | Includes Refunded? | Key Condition |
|
||||
|----------|-------------------|---------------|
|
||||
| **Unfinished** | No | NOT finished, NOT dropped |
|
||||
| **Dropped** | Yes | Finished OR Abandoned/Retired |
|
||||
| **Refunded** | Yes | `date_refunded IS NOT NULL` |
|
||||
| **Infinite** | Yes | `infinite = True` |
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Checking if a game is finished
|
||||
|
||||
```python
|
||||
game.finished() # Returns True if status="f" or has PlayEvent with ended date
|
||||
```
|
||||
|
||||
### Checking if a game is abandoned
|
||||
|
||||
```python
|
||||
game.abandoned() # Returns True if status="a"
|
||||
```
|
||||
|
||||
### Getting finished purchases
|
||||
|
||||
```python
|
||||
Purchase.objects.finished() # All purchases where games are finished
|
||||
```
|
||||
|
||||
### Getting dropped purchases
|
||||
|
||||
```python
|
||||
Purchase.objects.dropped() # All purchases that are abandoned or refunded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transition State
|
||||
|
||||
The system uses **OR logic** for both finished and dropped to catch any mismatch between explicit user actions and data signals:
|
||||
|
||||
- **Finished**: `status="f" OR PlayEvent.ended`
|
||||
- **Dropped**: `status="a" OR date_refunded`
|
||||
|
||||
This bridges the gap between the old model (where `date_finished` and `date_dropped` were on the Purchase model) and the new model (where `Game.status` and `PlayEvent` are the sources of truth).
|
||||
|
||||
**Future:** These signals should be kept in sync. For example:
|
||||
- Setting `Game.status = "f"` should create a PlayEvent with `ended` date
|
||||
- When the sync is reliable, the OR can be simplified to a single check
|
||||
|
||||
Note: Refunding a purchase always automatically sets its games' status to Abandoned. This is not optional — there is no way to refund without abandoning.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Unplayed games
|
||||
- Unplayed games (`status="u"`) are considered **unfinished**, not dropped
|
||||
- They appear in the unfinished/backlog list since they are still games the user may play
|
||||
- Unplayed games that are refunded DO count as **dropped** (refund signal overrides)
|
||||
|
||||
### Multiple games per purchase
|
||||
- A purchase can have multiple games via `Purchase.games` (many-to-many)
|
||||
- A purchase is finished if ANY of its games is finished
|
||||
- A purchase is dropped if ANY of its games is abandoned OR the purchase itself is refunded
|
||||
|
||||
### PlayEvents without ended date
|
||||
- A PlayEvent with `started` but no `ended` does NOT count as finished
|
||||
- This represents a game that was started but not completed
|
||||
|
||||
### Retired games
|
||||
- Retired games (`status="r"`) are considered **dropped**
|
||||
- Retirement is for games the user intentionally removed from their collection (collector's items, no longer accessible, etc.)
|
||||
@@ -0,0 +1,46 @@
|
||||
# Suggested Improvements to common/components.py
|
||||
|
||||
## Completed
|
||||
|
||||
### Caching on template rendering
|
||||
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
|
||||
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
|
||||
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
|
||||
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
|
||||
- Verified working: identical calls return identical output, different inputs produce separate cache entries
|
||||
|
||||
### Non-deterministic IDs
|
||||
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
|
||||
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
|
||||
- `games/templatetags/randomid.py` uses the same hash-based approach
|
||||
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
|
||||
|
||||
### Inconsistent return types
|
||||
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
|
||||
|
||||
### Fragile A() URL resolution
|
||||
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
|
||||
|
||||
### Toast XSS vulnerability
|
||||
The vulnerable `Toast()` component (which used unsafe string escaping for
|
||||
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
|
||||
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
|
||||
headers → `show-toast` CustomEvent → Alpine store.
|
||||
|
||||
### Default mutable arguments
|
||||
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
|
||||
|
||||
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
|
||||
|
||||
### NameWithIcon dead code and untestable design
|
||||
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
|
||||
|
||||
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
|
||||
|
||||
### No tests
|
||||
Zero test coverage for the entire component system.
|
||||
|
||||
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
|
||||
and cache hit/miss verification.
|
||||
|
||||
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
|
||||
@@ -0,0 +1,344 @@
|
||||
import hashlib
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.utils import truncate
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
def _render_cached_impl(template: str, context_json: str) -> str:
|
||||
context = json.loads(context_json)
|
||||
context["slot"] = mark_safe(context["slot"])
|
||||
return render_to_string(template, context)
|
||||
|
||||
|
||||
if not settings.DEBUG:
|
||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||
else:
|
||||
_render_cached = _render_cached_impl
|
||||
|
||||
|
||||
def enable_cache():
|
||||
"""Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
|
||||
global _render_cached
|
||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
template: str = "",
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
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)
|
||||
if len(attributes) == 0:
|
||||
attributesBlob = ""
|
||||
else:
|
||||
attributesList = [f'{name}="{value}"' for name, value in attributes]
|
||||
# make attribute list into a string
|
||||
# and insert space between tag and attribute list
|
||||
attributesBlob = f" {' '.join(attributesList)}"
|
||||
tag: str = ""
|
||||
if tag_name != "":
|
||||
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
|
||||
elif template != "":
|
||||
context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
|
||||
tag = _render_cached(template, json.dumps(context, sort_keys=True))
|
||||
return mark_safe(tag)
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = content_hash[:length] if not seed else content_hash[:max(0, length - len(seed))]
|
||||
return seed + base
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> str:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
return Component(
|
||||
attributes=attributes
|
||||
+ [
|
||||
("id", id),
|
||||
("wrapped_content", wrapped_content),
|
||||
("popover_content", popover_content),
|
||||
("wrapped_classes", wrapped_classes),
|
||||
],
|
||||
children=children,
|
||||
template="cotton/popover.html",
|
||||
)
|
||||
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
The truncated text ends in `ellipsis`, and optionally
|
||||
an always-visible `endpart` can be specified.
|
||||
`popover_content` can be specified if:
|
||||
1. It needs to be always displayed regardless if text is truncated.
|
||||
2. It needs to differ from `input_string`.
|
||||
"""
|
||||
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
|
||||
return Popover(
|
||||
wrapped_content=truncated,
|
||||
popover_content=popover_content if popover_content else input_string,
|
||||
)
|
||||
else:
|
||||
if popover_content and popover_if_not_truncated:
|
||||
return Popover(
|
||||
wrapped_content=input_string,
|
||||
popover_content=popover_content if popover_content else "",
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
Accepts one of two mutually-exclusive URL specifications:
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
|
||||
additional_attributes = []
|
||||
if url_name is not None:
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
template="cotton/button.html",
|
||||
attributes=attributes
|
||||
+ [
|
||||
("size", size),
|
||||
("icon", icon),
|
||||
("color", color),
|
||||
("class", "hover:cursor-pointer"),
|
||||
],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Div(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
|
||||
|
||||
def Form(
|
||||
action="",
|
||||
method="get",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="form",
|
||||
attributes=attributes + [("action", action), ("method", method)],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
try:
|
||||
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
|
||||
except TemplateDoesNotExist:
|
||||
result = Icon(name="unspecified", attributes=attributes)
|
||||
return result
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = purchase.platform.icon if game_count == 1 else "unspecified"
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+211
-24
@@ -1,46 +1,233 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
form label {
|
||||
@apply dark:text-slate-400;
|
||||
@plugin '@tailwindcss/typography';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin 'flowbite/plugin';
|
||||
|
||||
@source "../node_modules/flowbite";
|
||||
@import "flowbite/src/themes/default";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-mono:
|
||||
IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
--font-serif:
|
||||
IBM Plex Serif, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-condensed:
|
||||
IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--color-accent: #7c3aed;
|
||||
--color-background: #1f2937;
|
||||
}
|
||||
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
#session-table {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr repeat(2, 1fr) 0.5fr 1fr;
|
||||
@utility min-w-20char {
|
||||
min-width: 20ch;
|
||||
}
|
||||
|
||||
.purchase-name > span:nth-child(2) {
|
||||
@apply ml-4
|
||||
@utility max-w-20char {
|
||||
max-width: 20ch;
|
||||
}
|
||||
|
||||
.purchase-name > span:nth-child(2) > a > img {
|
||||
@apply opacity-0 transition-opacity duration-500
|
||||
@utility min-w-30char {
|
||||
min-width: 30ch;
|
||||
}
|
||||
|
||||
.purchase-name:hover > span:nth-child(2) > a > img {
|
||||
@apply opacity-50
|
||||
@utility max-w-30char {
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.purchase-name > span:nth-child(2) > a > img:hover {
|
||||
@apply opacity-100
|
||||
@utility max-w-35char {
|
||||
max-width: 35ch;
|
||||
}
|
||||
|
||||
@utility max-w-40char {
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Serif';
|
||||
src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Serif';
|
||||
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Condensed';
|
||||
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.responsive-table {
|
||||
@apply dark:text-white mx-auto table-fixed;
|
||||
}
|
||||
|
||||
.responsive-table tr:nth-child(even) {
|
||||
@apply bg-indigo-100 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
.responsive-table tbody tr:nth-child(odd) {
|
||||
@apply bg-indigo-200 dark: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;
|
||||
}
|
||||
}
|
||||
|
||||
form input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
|
||||
|
||||
}
|
||||
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
||||
}
|
||||
|
||||
#button-container button {
|
||||
@apply mx-1;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply text-left;
|
||||
.basic-button-container {
|
||||
@apply flex space-x-2 justify-center;
|
||||
}
|
||||
|
||||
th label {
|
||||
@apply mr-4;
|
||||
.basic-button {
|
||||
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded-sm shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-hidden focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
|
||||
}
|
||||
|
||||
.markdown-content ul {
|
||||
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;
|
||||
}
|
||||
|
||||
#add-form {
|
||||
label + select, input, textarea {
|
||||
@apply mt-1;
|
||||
}
|
||||
form {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.form-row-button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@apply gap-0 p-0;
|
||||
button {
|
||||
@apply mr-0;
|
||||
&:first-child {
|
||||
@apply rounded-e-none;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
@apply rounded-none;
|
||||
}
|
||||
&:last-child {
|
||||
@apply rounded-s-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
label {
|
||||
@apply mb-2.5 text-sm font-medium text-heading;
|
||||
}
|
||||
input:not([type="checkbox"]) {
|
||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
|
||||
}
|
||||
select {
|
||||
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
|
||||
}
|
||||
textarea {
|
||||
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
:has(> label + input[type="checkbox"]) {
|
||||
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.toast-container {
|
||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.dates as mdates
|
||||
import pandas as pd
|
||||
from django.db.models import F, IntegerField, QuerySet, Sum
|
||||
from django.db.models.functions import TruncDay
|
||||
from games.models import Session
|
||||
|
||||
|
||||
def key_value_to_value_value(data):
|
||||
return {data["date"]: data["hours"]}
|
||||
|
||||
|
||||
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
|
||||
microsecond_in_second = 1000000
|
||||
result = (
|
||||
queryset.exclude(timestamp_end__exact=None)
|
||||
.annotate(date=TruncDay("timestamp_start"))
|
||||
.values("date")
|
||||
.annotate(
|
||||
hours=Sum(
|
||||
F("duration_calculated"),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.values("date", "hours")
|
||||
)
|
||||
keys = []
|
||||
values = []
|
||||
running_total = int(0)
|
||||
for item in result:
|
||||
# date_value = datetime.strftime(item["date"], "%d-%m-%Y")
|
||||
date_value = item["date"]
|
||||
keys.append(date_value)
|
||||
running_total += int(item["hours"] / (3600 * microsecond_in_second))
|
||||
values.append(running_total)
|
||||
data = [keys, values]
|
||||
return get_chart(
|
||||
data,
|
||||
title="Playtime over time (manual excluded)",
|
||||
xlabel="Date",
|
||||
ylabel="Hours",
|
||||
)
|
||||
|
||||
|
||||
def get_graph():
|
||||
buffer = BytesIO()
|
||||
plt.savefig(buffer, format="svg", transparent=True)
|
||||
buffer.seek(0)
|
||||
image_png = buffer.getvalue()
|
||||
graph = base64.b64encode(image_png)
|
||||
graph = graph.decode("utf-8")
|
||||
buffer.close()
|
||||
return graph
|
||||
|
||||
|
||||
def get_chart(data, title="", xlabel="", ylabel=""):
|
||||
x = data[0]
|
||||
y = data[1]
|
||||
plt.style.use("dark_background")
|
||||
plt.switch_backend("SVG")
|
||||
fig, ax = plt.subplots()
|
||||
fig.set_size_inches(10, 4)
|
||||
lines = ax.plot(x, y, "-o")
|
||||
first = x[0]
|
||||
last = x[-1]
|
||||
difference = last - first
|
||||
if difference.days <= 14:
|
||||
ax.xaxis.set_major_locator(mdates.DayLocator())
|
||||
elif difference.days < 60 or len(x) < 60:
|
||||
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
|
||||
ax.xaxis.set_minor_locator(mdates.DayLocator())
|
||||
elif difference.days < 720:
|
||||
ax.xaxis.set_major_locator(mdates.MonthLocator())
|
||||
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
|
||||
for line in lines:
|
||||
line.set_marker("")
|
||||
else:
|
||||
for line in lines:
|
||||
line.set_marker("")
|
||||
ax.xaxis.set_major_locator(mdates.YearLocator())
|
||||
ax.xaxis.set_minor_locator(mdates.MonthLocator())
|
||||
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
|
||||
for label in ax.get_xticklabels(which="major"):
|
||||
label.set(rotation=30, horizontalalignment="right")
|
||||
ax.set_xlabel(xlabel)
|
||||
ax.set_ylabel(ylabel)
|
||||
ax.set_title(title)
|
||||
fig.tight_layout()
|
||||
chart = get_graph()
|
||||
return chart
|
||||
+125
-19
@@ -1,16 +1,19 @@
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from common.utils import generate_split_ranges
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
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):
|
||||
if duration == None:
|
||||
if duration is None:
|
||||
return timedelta(0)
|
||||
elif isinstance(duration, int):
|
||||
return timedelta(seconds=duration)
|
||||
@@ -19,7 +22,7 @@ def _safe_timedelta(duration: timedelta | int | None):
|
||||
|
||||
|
||||
def format_duration(
|
||||
duration: timedelta | int | None, format_string: str = "%H hours"
|
||||
duration: timedelta | int | float | None, format_string: str = "%H hours"
|
||||
) -> str:
|
||||
"""
|
||||
Format timedelta into the specified format_string.
|
||||
@@ -32,32 +35,135 @@ def format_duration(
|
||||
from the formatting string. For example:
|
||||
- 61 seconds as "%s" = 61 seconds
|
||||
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
|
||||
Format specifiers can include width and precision options:
|
||||
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
|
||||
"""
|
||||
minute_seconds = 60
|
||||
hour_seconds = 60 * minute_seconds
|
||||
day_seconds = 24 * hour_seconds
|
||||
duration = _safe_timedelta(duration)
|
||||
safe_duration = _safe_timedelta(duration)
|
||||
# we don't need float
|
||||
seconds_total = int(duration.total_seconds())
|
||||
seconds_total = int(safe_duration.total_seconds())
|
||||
# timestamps where end is before start
|
||||
if seconds_total < 0:
|
||||
seconds_total = 0
|
||||
days = hours = minutes = seconds = 0
|
||||
days = hours = hours_float = minutes = seconds = 0
|
||||
remainder = seconds = seconds_total
|
||||
if "%d" in format_string:
|
||||
days, remainder = divmod(seconds_total, day_seconds)
|
||||
if "%H" in format_string:
|
||||
hours, remainder = divmod(remainder, hour_seconds)
|
||||
if "%m" in format_string:
|
||||
if re.search(r"%\d*\.?\d*H", format_string):
|
||||
hours_float, remainder = divmod(remainder, hour_seconds)
|
||||
hours = float(hours_float) + remainder / hour_seconds
|
||||
if re.search(r"%\d*\.?\d*m", format_string):
|
||||
minutes, seconds = divmod(remainder, minute_seconds)
|
||||
literals = {
|
||||
"%d": str(days),
|
||||
"%H": str(hours),
|
||||
"%m": str(minutes),
|
||||
"%s": str(seconds),
|
||||
"%r": str(seconds_total),
|
||||
"d": str(days),
|
||||
"H": str(hours) if "m" not in format_string else str(hours_float),
|
||||
"m": str(minutes),
|
||||
"s": str(seconds),
|
||||
"r": str(seconds_total),
|
||||
}
|
||||
formatted_string = format_string
|
||||
for pattern, replacement in literals.items():
|
||||
formatted_string = re.sub(pattern, replacement, formatted_string)
|
||||
# Match format specifiers with optional width and precision
|
||||
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
|
||||
if match:
|
||||
format_spec = match.group(1)
|
||||
if "." in format_spec:
|
||||
# Format the number as float if precision is specified
|
||||
replacement = f"{float(replacement):{format_spec}f}"
|
||||
else:
|
||||
# Format the number as integer if no precision is specified
|
||||
replacement = f"{int(float(replacement)):>{format_spec}}"
|
||||
# Replace the format specifier with the formatted number
|
||||
formatted_string = re.sub(
|
||||
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
|
||||
)
|
||||
return formatted_string
|
||||
|
||||
|
||||
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
|
||||
return timezone.localtime(datetime).strftime(format)
|
||||
|
||||
|
||||
def daterange(start: date, end: date, end_inclusive: bool = False) -> list[date]:
|
||||
time_between: timedelta = end - start
|
||||
if (days_between := time_between.days) < 1:
|
||||
raise ValueError("start and end have to be at least 1 day apart.")
|
||||
if end_inclusive:
|
||||
print(f"{end_inclusive=}")
|
||||
print(f"{days_between=}")
|
||||
days_between += 1
|
||||
print(f"{days_between=}")
|
||||
return [start + timedelta(x) for x in range(days_between)]
|
||||
|
||||
|
||||
def streak(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
|
||||
if len(datelist) == 1:
|
||||
return {"days": 1, "dates": (datelist[0], datelist[0])}
|
||||
else:
|
||||
print(f"Processing {len(datelist)} dates.")
|
||||
missing = sorted(
|
||||
set(
|
||||
datelist[0] + timedelta(x)
|
||||
for x in range((datelist[-1] - datelist[0]).days)
|
||||
)
|
||||
- set(datelist)
|
||||
)
|
||||
print(f"{len(missing)} days missing.")
|
||||
datelist_with_missing = sorted(datelist + missing)
|
||||
ranges = list(generate_split_ranges(datelist_with_missing, missing))
|
||||
print(f"{len(ranges)} ranges calculated.")
|
||||
longest_consecutive_days = timedelta(0)
|
||||
longest_range: tuple[date, date] = (date(1970, 1, 1), date(1970, 1, 1))
|
||||
for start, end in ranges:
|
||||
if (current_streak := end - start) > longest_consecutive_days:
|
||||
longest_consecutive_days = current_streak
|
||||
longest_range = (start, end)
|
||||
return {"days": longest_consecutive_days.days + 1, "dates": longest_range}
|
||||
|
||||
|
||||
def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
|
||||
if (datelist_length := len(datelist)) == 0:
|
||||
raise ValueError("Number of dates in the list is 0.")
|
||||
datelist.sort()
|
||||
current_streak = 1
|
||||
current_start = datelist[0]
|
||||
current_end = datelist[0]
|
||||
current_date = datelist[0]
|
||||
highest_streak = 1
|
||||
highest_streak_daterange = (current_start, current_end)
|
||||
|
||||
def update_highest_streak():
|
||||
nonlocal highest_streak, highest_streak_daterange
|
||||
if current_streak > highest_streak:
|
||||
highest_streak = current_streak
|
||||
highest_streak_daterange = (current_start, current_end)
|
||||
|
||||
def reset_streak():
|
||||
nonlocal current_start, current_end, current_streak
|
||||
current_start = current_end = current_date
|
||||
current_streak = 1
|
||||
|
||||
def increment_streak():
|
||||
nonlocal current_end, current_streak
|
||||
current_end = current_date
|
||||
current_streak += 1
|
||||
|
||||
for i, datelist_item in enumerate(datelist, start=1):
|
||||
current_date = datelist_item
|
||||
if current_date == current_start or current_date == current_end:
|
||||
continue
|
||||
if current_date - timedelta(1) != current_end and i != datelist_length:
|
||||
update_highest_streak()
|
||||
reset_streak()
|
||||
elif current_date - timedelta(1) == current_end and i == datelist_length:
|
||||
increment_streak()
|
||||
update_highest_streak()
|
||||
else:
|
||||
increment_streak()
|
||||
return {"days": highest_streak, "dates": highest_streak_daterange}
|
||||
|
||||
|
||||
def available_stats_year_range():
|
||||
return range(datetime.now().year, 1999, -1)
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import operator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from functools import reduce, wraps
|
||||
from typing import Any, Callable, Generator, Literal, TypeVar
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
||||
"""
|
||||
Divides without triggering division by zero exception.
|
||||
Returns 0 if denominator is 0.
|
||||
"""
|
||||
try:
|
||||
return numerator / denominator
|
||||
except ZeroDivisionError:
|
||||
return 0
|
||||
|
||||
|
||||
def safe_getattr(obj: 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)].rstrip()}{ellipsis}")
|
||||
if len(input_string) > length
|
||||
else input_string
|
||||
)
|
||||
|
||||
|
||||
def truncate(
|
||||
input_string: str, length: int = 30, ellipsis: str = "…", endpart: str = ""
|
||||
) -> str:
|
||||
max_content_length = length - len(endpart)
|
||||
if max_content_length < 0:
|
||||
raise ValueError("Length cannot be shorter than the length of endpart.")
|
||||
|
||||
if len(input_string) > max_content_length:
|
||||
return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
|
||||
|
||||
return (
|
||||
f"{input_string}{endpart}"
|
||||
if len(input_string) + len(endpart) <= length
|
||||
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T", str, int, date)
|
||||
|
||||
|
||||
def generate_split_ranges(
|
||||
value_list: list[T], split_points: list[T]
|
||||
) -> Generator[tuple[T, T], None, None]:
|
||||
for x in range(0, len(split_points) + 1):
|
||||
if x == 0:
|
||||
start = 0
|
||||
elif x >= len(split_points):
|
||||
start = value_list.index(split_points[x - 1]) + 1
|
||||
else:
|
||||
start = value_list.index(split_points[x - 1]) + 1
|
||||
try:
|
||||
end = value_list.index(split_points[x])
|
||||
except IndexError:
|
||||
end = len(value_list)
|
||||
yield (value_list[start], value_list[end - 1])
|
||||
|
||||
|
||||
def format_float_or_int(number: int | float):
|
||||
return int(number) if float(number).is_integer() else f"{number:03.2f}"
|
||||
|
||||
|
||||
OperatorType = Literal["|", "&"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterEntry:
|
||||
condition: Q
|
||||
operator: OperatorType = "&"
|
||||
|
||||
|
||||
def build_dynamic_filter(
|
||||
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
|
||||
):
|
||||
"""
|
||||
Constructs a Django Q filter from a list of filter conditions.
|
||||
|
||||
Args:
|
||||
filters (list): A list where each item is either:
|
||||
- A Q object (default AND logic applied)
|
||||
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
|
||||
|
||||
Returns:
|
||||
Q: A combined Q object that can be passed to Django's filter().
|
||||
"""
|
||||
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
|
||||
"|": operator.or_,
|
||||
"&": operator.and_,
|
||||
}
|
||||
|
||||
# Convert all plain Q objects into (Q, "&") for default AND behavior
|
||||
processed_filters = [
|
||||
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
|
||||
]
|
||||
|
||||
# Reduce with dynamic operators
|
||||
return reduce(
|
||||
lambda combined_filters, filter: op_map[filter.operator](
|
||||
combined_filters, filter.condition
|
||||
),
|
||||
processed_filters,
|
||||
Q(),
|
||||
)
|
||||
|
||||
|
||||
def redirect_to(default_view: str, *default_args):
|
||||
"""
|
||||
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
|
||||
|
||||
:param default_view: The name of the default view to redirect to if 'next' is missing.
|
||||
:param default_args: Any arguments required for the default view.
|
||||
"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped_view(request: HttpRequest, *args, **kwargs):
|
||||
next_url = request.GET.get("next")
|
||||
if not next_url:
|
||||
from django.urls import (
|
||||
reverse, # Import inside function to avoid circular imports
|
||||
)
|
||||
|
||||
next_url = reverse(default_view, args=default_args)
|
||||
|
||||
response = view_func(
|
||||
request, *args, **kwargs
|
||||
) # Execute the original view logic
|
||||
return redirect(next_url)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def add_next_param_to_url(url: str, nexturl: str) -> str:
|
||||
return f"{url}?{urlencode({'next': nexturl})}"
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json"
|
||||
date_format = "%Y%m%d"
|
||||
years = range(2000, datetime.now().year + 1)
|
||||
dates = [
|
||||
datetime.strftime(datetime(day=1, month=1, year=year), format=date_format)
|
||||
for year in years
|
||||
]
|
||||
for date in dates:
|
||||
final_url = url.format(date)
|
||||
year = date[:4]
|
||||
response = requests.get(final_url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if kurzy := data.get("kurzy"):
|
||||
with open("output.yaml", mode="a") as o:
|
||||
rates = [
|
||||
f"""
|
||||
- model: games.exchangerate
|
||||
fields:
|
||||
currency_from: {currency_name}
|
||||
currency_to: CZK
|
||||
year: {year}
|
||||
rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)}
|
||||
"""
|
||||
for currency_name in ["EUR", "USD", "CNY"]
|
||||
if kurzy.get(currency_name)
|
||||
]
|
||||
o.writelines(rates)
|
||||
# time.sleep(0.5)
|
||||
@@ -0,0 +1,65 @@
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(filename):
|
||||
with open(filename, "r", encoding="utf-8") as file:
|
||||
return yaml.safe_load(file) or []
|
||||
|
||||
|
||||
def save_yaml(filename, data):
|
||||
with open(filename, "w", encoding="utf-8") as file:
|
||||
yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
|
||||
def extract_existing_combinations(data):
|
||||
return {
|
||||
(
|
||||
entry["fields"]["currency_from"],
|
||||
entry["fields"]["currency_to"],
|
||||
entry["fields"]["year"],
|
||||
)
|
||||
for entry in data
|
||||
if entry["model"] == "games.exchangerate"
|
||||
}
|
||||
|
||||
|
||||
def filter_new_entries(existing_combinations, additional_files):
|
||||
new_entries = []
|
||||
|
||||
for filename in additional_files:
|
||||
data = load_yaml(filename)
|
||||
for entry in data:
|
||||
if entry["model"] == "games.exchangerate":
|
||||
key = (
|
||||
entry["fields"]["currency_from"],
|
||||
entry["fields"]["currency_to"],
|
||||
entry["fields"]["year"],
|
||||
)
|
||||
if key not in existing_combinations:
|
||||
new_entries.append(entry)
|
||||
|
||||
return new_entries
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]")
|
||||
sys.exit(1)
|
||||
|
||||
example_file = sys.argv[1]
|
||||
additional_files = sys.argv[2:]
|
||||
output_file = "filtered_output.yaml"
|
||||
|
||||
existing_data = load_yaml(example_file)
|
||||
existing_combinations = extract_existing_combinations(existing_data)
|
||||
|
||||
new_entries = filter_new_entries(existing_combinations, additional_files)
|
||||
|
||||
save_yaml(output_file, new_entries)
|
||||
print(f"Filtered data saved to {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Set up environment
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /workspace
|
||||
|
||||
# Install Poetry
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
make \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# Copy pyproject.toml and poetry.lock for dependency installation
|
||||
COPY pyproject.toml poetry.lock* ./
|
||||
RUN poetry install --no-root
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Set up Django development server
|
||||
EXPOSE 8000
|
||||
+12
-19
@@ -1,28 +1,21 @@
|
||||
---
|
||||
services:
|
||||
backend:
|
||||
image: registry.kucharczyk.xyz/timetracker
|
||||
timetracker:
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- TZ=Europe/Prague
|
||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||
user: "1000"
|
||||
- TZ=${TZ:-Europe/Prague}
|
||||
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-100}
|
||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||
ports:
|
||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- "static-files:/home/timetracker/app/static"
|
||||
- "./data:/home/timetracker/app/data"
|
||||
- "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: caddy
|
||||
volumes:
|
||||
- "static-files:/usr/share/caddy"
|
||||
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
static-files:
|
||||
|
||||
|
||||
+19
-7
@@ -1,11 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Apply database migrations
|
||||
set -euo pipefail
|
||||
echo "Apply database migrations"
|
||||
poetry run python manage.py migrate
|
||||
|
||||
echo "Collect static files"
|
||||
poetry run python manage.py collectstatic --clear --no-input
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-100}
|
||||
|
||||
echo "Starting app"
|
||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
|
||||
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
||||
usermod -d "/root" timetracker
|
||||
groupmod -o -g "$PGID" timetracker
|
||||
usermod -o -u "$PUID" timetracker
|
||||
usermod -d "${USERHOME}" timetracker
|
||||
|
||||
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
||||
chmod 755 /home/timetracker/app
|
||||
chmod 755 /home/timetracker/app/.venv
|
||||
|
||||
chown "$PUID:$PGID" /home/timetracker/app/data
|
||||
chown "$PUID:$PGID" /var/log/supervisor
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic --clear --no-input
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||
|
||||
+10
-1
@@ -1,9 +1,18 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
from games.models import (
|
||||
Device,
|
||||
ExchangeRate,
|
||||
Game,
|
||||
Platform,
|
||||
Purchase,
|
||||
Session,
|
||||
)
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(Game)
|
||||
admin.site.register(Purchase)
|
||||
admin.site.register(Platform)
|
||||
admin.site.register(Session)
|
||||
admin.site.register(Device)
|
||||
admin.site.register(ExchangeRate)
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now as django_timezone_now
|
||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
|
||||
|
||||
from games.models import Game, PlayEvent, Session
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
game_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
|
||||
class GameStatusUpdate(Schema):
|
||||
status: str
|
||||
|
||||
|
||||
class PlayEventIn(Schema):
|
||||
game_id: int
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
days_to_finish: int | None = None
|
||||
|
||||
|
||||
class AutoPlayEventIn(ModelSchema):
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = ["game", "started", "ended", "note"]
|
||||
|
||||
|
||||
class UpdatePlayEventIn(Schema):
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class PlayEventOut(Schema):
|
||||
id: int
|
||||
game: str = Field(..., alias="game.name")
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
days_to_finish: int | None = None
|
||||
note: str = ""
|
||||
updated_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@game_router.patch("/{game_id}/status", response={204: None})
|
||||
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
setattr(game, "status", payload.status)
|
||||
game.save()
|
||||
messages.success(request, "Status updated")
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
@playevent_router.get("/", response=List[PlayEventOut])
|
||||
def list_playevents(request):
|
||||
return PlayEvent.objects.all()
|
||||
|
||||
|
||||
@playevent_router.post("/", response={201: PlayEventOut})
|
||||
def create_playevent(request, payload: PlayEventIn):
|
||||
playevent = PlayEvent.objects.create(**payload.dict())
|
||||
messages.success(request, "Game played!")
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
|
||||
def get_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
|
||||
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
for attr, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(playevent, attr, value)
|
||||
playevent.save()
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.delete("/{playevent_id}", response={204: None})
|
||||
def delete_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
playevent.delete()
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
||||
api.add_router("/games", game_router)
|
||||
|
||||
session_router = Router()
|
||||
|
||||
|
||||
class SessionDeviceUpdate(Schema):
|
||||
device_id: int
|
||||
|
||||
|
||||
@session_router.patch("/{session_id}/device", response={204: None})
|
||||
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
session.device_id = payload.device_id
|
||||
session.save()
|
||||
messages.success(request, "Device updated")
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
api.add_router("/session", session_router)
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
# from datetime import timedelta
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.management import call_command
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
# from django.utils.timezone import now
|
||||
|
||||
|
||||
class GamesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "games"
|
||||
|
||||
def ready(self):
|
||||
import games.signals # noqa: F401
|
||||
|
||||
post_migrate.connect(schedule_tasks, sender=self)
|
||||
|
||||
|
||||
def schedule_tasks(sender, **kwargs):
|
||||
# from django_q.models import Schedule
|
||||
# from django_q.tasks import schedule
|
||||
|
||||
# if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
# schedule(
|
||||
# "games.tasks.convert_prices",
|
||||
# name="Update converted prices",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
# if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
# schedule(
|
||||
# "games.tasks.calculate_price_per_game",
|
||||
# name="Update price per game",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
from games.models import ExchangeRate
|
||||
|
||||
if not ExchangeRate.objects.exists():
|
||||
print("ExchangeRate table is empty. Loading fixture...")
|
||||
call_command("loaddata", "exchangerates.yaml")
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
- model: games.exchangerate
|
||||
pk: 1
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2024
|
||||
rate: 23.4
|
||||
- model: games.exchangerate
|
||||
pk: 2
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2024
|
||||
rate: 3.267
|
||||
- model: games.exchangerate
|
||||
pk: 3
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2019
|
||||
rate: 22.466
|
||||
- model: games.exchangerate
|
||||
pk: 4
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2023
|
||||
rate: 22.63
|
||||
- model: games.exchangerate
|
||||
pk: 5
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2017
|
||||
rate: 25.819
|
||||
- model: games.exchangerate
|
||||
pk: 6
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2013
|
||||
rate: 19.023
|
||||
- model: games.exchangerate
|
||||
pk: 7
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2019
|
||||
rate: 3.295
|
||||
- model: games.exchangerate
|
||||
pk: 8
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2016
|
||||
rate: 3.795
|
||||
- model: games.exchangerate
|
||||
pk: 9
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2015
|
||||
rate: 3.707
|
||||
- model: games.exchangerate
|
||||
pk: 10
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2020
|
||||
rate: 3.26
|
||||
- model: games.exchangerate
|
||||
pk: 11
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2012
|
||||
rate: 25.51
|
||||
- model: games.exchangerate
|
||||
pk: 12
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2010
|
||||
rate: 26.465
|
||||
- model: games.exchangerate
|
||||
pk: 13
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2014
|
||||
rate: 27.52
|
||||
- model: games.exchangerate
|
||||
pk: 14
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2024
|
||||
rate: 25.21
|
||||
- model: games.exchangerate
|
||||
pk: 15
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2022
|
||||
rate: 24.325
|
||||
- model: games.exchangerate
|
||||
pk: 16
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2018
|
||||
rate: 3.268
|
||||
- model: games.exchangerate
|
||||
pk: 17
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2023
|
||||
rate: 3.281
|
||||
- model: games.exchangerate
|
||||
pk: 18
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2009
|
||||
rate: 26.445
|
||||
- model: games.exchangerate
|
||||
pk: 19
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2025
|
||||
rate: 3.35
|
||||
- model: games.exchangerate
|
||||
pk: 20
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2016
|
||||
rate: 27.033
|
||||
- model: games.exchangerate
|
||||
pk: 21
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2025
|
||||
rate: 25.2021966
|
||||
- model: games.exchangerate
|
||||
pk: 22
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2017
|
||||
rate: 26.33
|
||||
- model: games.exchangerate
|
||||
pk: 23
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2000
|
||||
rate: 36.13
|
||||
- model: games.exchangerate
|
||||
pk: 24
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2000
|
||||
rate: 35.979
|
||||
- model: games.exchangerate
|
||||
pk: 25
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2001
|
||||
rate: 35.09
|
||||
- model: games.exchangerate
|
||||
pk: 26
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2001
|
||||
rate: 37.813
|
||||
- model: games.exchangerate
|
||||
pk: 27
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2002
|
||||
rate: 31.98
|
||||
- model: games.exchangerate
|
||||
pk: 28
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2002
|
||||
rate: 36.259
|
||||
- model: games.exchangerate
|
||||
pk: 29
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2003
|
||||
rate: 31.6
|
||||
- model: games.exchangerate
|
||||
pk: 30
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2003
|
||||
rate: 30.141
|
||||
- model: games.exchangerate
|
||||
pk: 31
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2004
|
||||
rate: 32.405
|
||||
- model: games.exchangerate
|
||||
pk: 32
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2004
|
||||
rate: 25.654
|
||||
- model: games.exchangerate
|
||||
pk: 33
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2005
|
||||
rate: 30.465
|
||||
- model: games.exchangerate
|
||||
pk: 34
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2005
|
||||
rate: 22.365
|
||||
- model: games.exchangerate
|
||||
pk: 35
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2006
|
||||
rate: 29.005
|
||||
- model: games.exchangerate
|
||||
pk: 36
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2006
|
||||
rate: 24.588
|
||||
- model: games.exchangerate
|
||||
pk: 37
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2006
|
||||
rate: 3.047
|
||||
- model: games.exchangerate
|
||||
pk: 38
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2007
|
||||
rate: 27.495
|
||||
- model: games.exchangerate
|
||||
pk: 39
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2007
|
||||
rate: 20.876
|
||||
- model: games.exchangerate
|
||||
pk: 40
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2007
|
||||
rate: 2.674
|
||||
- model: games.exchangerate
|
||||
pk: 41
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2008
|
||||
rate: 26.62
|
||||
- model: games.exchangerate
|
||||
pk: 42
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2008
|
||||
rate: 18.078
|
||||
- model: games.exchangerate
|
||||
pk: 43
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2008
|
||||
rate: 2.475
|
||||
- model: games.exchangerate
|
||||
pk: 44
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2009
|
||||
rate: 19.346
|
||||
- model: games.exchangerate
|
||||
pk: 45
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2009
|
||||
rate: 2.836
|
||||
- model: games.exchangerate
|
||||
pk: 46
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2010
|
||||
rate: 18.368
|
||||
- model: games.exchangerate
|
||||
pk: 47
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2010
|
||||
rate: 2.691
|
||||
- model: games.exchangerate
|
||||
pk: 48
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2011
|
||||
rate: 25.06
|
||||
- model: games.exchangerate
|
||||
pk: 49
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2011
|
||||
rate: 18.751
|
||||
- model: games.exchangerate
|
||||
pk: 50
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2011
|
||||
rate: 2.845
|
||||
- model: games.exchangerate
|
||||
pk: 51
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2012
|
||||
rate: 19.94
|
||||
- model: games.exchangerate
|
||||
pk: 52
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2012
|
||||
rate: 3.168
|
||||
- model: games.exchangerate
|
||||
pk: 53
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2013
|
||||
rate: 25.14
|
||||
- model: games.exchangerate
|
||||
pk: 54
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2013
|
||||
rate: 3.059
|
||||
- model: games.exchangerate
|
||||
pk: 55
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2014
|
||||
rate: 19.894
|
||||
- model: games.exchangerate
|
||||
pk: 56
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2014
|
||||
rate: 3.286
|
||||
- model: games.exchangerate
|
||||
pk: 57
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2015
|
||||
rate: 27.725
|
||||
- model: games.exchangerate
|
||||
pk: 58
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2015
|
||||
rate: 22.834
|
||||
- model: games.exchangerate
|
||||
pk: 59
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2016
|
||||
rate: 24.824
|
||||
- model: games.exchangerate
|
||||
pk: 60
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2017
|
||||
rate: 3.693
|
||||
- model: games.exchangerate
|
||||
pk: 61
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2018
|
||||
rate: 25.54
|
||||
- model: games.exchangerate
|
||||
pk: 62
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2018
|
||||
rate: 21.291
|
||||
- model: games.exchangerate
|
||||
pk: 63
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2019
|
||||
rate: 25.725
|
||||
- model: games.exchangerate
|
||||
pk: 64
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2020
|
||||
rate: 25.41
|
||||
- model: games.exchangerate
|
||||
pk: 65
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2020
|
||||
rate: 22.621
|
||||
- model: games.exchangerate
|
||||
pk: 66
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2021
|
||||
rate: 26.245
|
||||
- model: games.exchangerate
|
||||
pk: 67
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2021
|
||||
rate: 21.387
|
||||
- model: games.exchangerate
|
||||
pk: 68
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2021
|
||||
rate: 3.273
|
||||
- model: games.exchangerate
|
||||
pk: 69
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2022
|
||||
rate: 21.951
|
||||
- model: games.exchangerate
|
||||
pk: 70
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2022
|
||||
rate: 3.458
|
||||
- model: games.exchangerate
|
||||
pk: 71
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2023
|
||||
rate: 24.115
|
||||
- model: games.exchangerate
|
||||
pk: 72
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2025
|
||||
rate: 24.237
|
||||
+215
-16
@@ -1,68 +1,267 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
|
||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
|
||||
from common.utils import safe_getattr
|
||||
from games.models import (
|
||||
Device,
|
||||
Game,
|
||||
GameStatusChange,
|
||||
Platform,
|
||||
PlayEvent,
|
||||
Purchase,
|
||||
Session,
|
||||
)
|
||||
|
||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||
custom_datetime_widget = forms.DateTimeInput(
|
||||
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
|
||||
)
|
||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.order_by("edition__name")
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
duration_manual = forms.DurationField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
|
||||
),
|
||||
label="Manual duration",
|
||||
)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||
|
||||
mark_as_played = forms.BooleanField(
|
||||
required=False,
|
||||
initial={"mark_as_played": True},
|
||||
label="Set game status to Played if Unplayed",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
widgets = {
|
||||
"timestamp_start": custom_datetime_widget,
|
||||
"timestamp_end": custom_datetime_widget,
|
||||
}
|
||||
model = Session
|
||||
fields = [
|
||||
"purchase",
|
||||
"game",
|
||||
"timestamp_start",
|
||||
"timestamp_end",
|
||||
"duration_manual",
|
||||
"emulated",
|
||||
"device",
|
||||
"note",
|
||||
"mark_as_played",
|
||||
]
|
||||
|
||||
def save(self, commit=True):
|
||||
session = super().save(commit=False)
|
||||
if self.cleaned_data.get("mark_as_played"):
|
||||
game_instance = session.game
|
||||
if game_instance.status == "u":
|
||||
game_instance.status = "p"
|
||||
if commit:
|
||||
game_instance.save()
|
||||
if commit:
|
||||
session.save()
|
||||
return session
|
||||
|
||||
class EditionChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
class IncludePlatformSelect(forms.SelectMultiple):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||
option["attrs"]["data-platform"] = platform_id
|
||||
return option
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
edition = EditionChoiceField(queryset=Edition.objects.order_by("name"))
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Automatically update related_purchase <select/>
|
||||
# to only include purchases of the selected game.
|
||||
related_purchase_by_game_url = reverse("games:related_purchase_by_game")
|
||||
self.fields["games"].widget.attrs.update(
|
||||
{
|
||||
"hx-trigger": "load, click",
|
||||
"hx-get": related_purchase_by_game_url,
|
||||
"hx-target": "#id_related_purchase",
|
||||
"hx-swap": "outerHTML",
|
||||
}
|
||||
)
|
||||
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME),
|
||||
required=False,
|
||||
)
|
||||
|
||||
price_currency = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"x-mask": "aaa",
|
||||
"placeholder": "CZK",
|
||||
"x-data": "",
|
||||
"class": "uppercase",
|
||||
}
|
||||
),
|
||||
label="Currency",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
widgets = {
|
||||
"date_purchased": custom_date_widget,
|
||||
"date_refunded": custom_date_widget,
|
||||
}
|
||||
model = Purchase
|
||||
fields = [
|
||||
"edition",
|
||||
"games",
|
||||
"platform",
|
||||
"date_purchased",
|
||||
"date_refunded",
|
||||
"infinite",
|
||||
"price",
|
||||
"price_currency",
|
||||
"ownership_type",
|
||||
"type",
|
||||
"related_purchase",
|
||||
"name",
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
purchase_type = cleaned_data.get("type")
|
||||
related_purchase = cleaned_data.get("related_purchase")
|
||||
name = cleaned_data.get("name")
|
||||
|
||||
class EditionForm(forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
# Set the type on the instance to use get_type_display()
|
||||
# This is safe because we're not saving the instance.
|
||||
self.instance.type = purchase_type
|
||||
|
||||
class Meta:
|
||||
model = Edition
|
||||
fields = ["game", "name", "platform", "year_released", "wikidata"]
|
||||
if purchase_type != Purchase.GAME:
|
||||
type_display = self.instance.get_type_display()
|
||||
if not related_purchase:
|
||||
self.add_error(
|
||||
"related_purchase",
|
||||
f"{type_display} must have a related purchase.",
|
||||
)
|
||||
if not name:
|
||||
self.add_error("name", f"{type_display} must have a name.")
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class IncludeNameSelect(forms.Select):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if value:
|
||||
option["attrs"]["data-name"] = value.instance.name
|
||||
option["attrs"]["data-year"] = value.instance.year_released
|
||||
return option
|
||||
|
||||
|
||||
class GameModelChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
# Use sort_name as the label for the option
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Game
|
||||
fields = ["name", "wikidata"]
|
||||
fields = [
|
||||
"name",
|
||||
"sort_name",
|
||||
"platform",
|
||||
"original_year_released",
|
||||
"year_released",
|
||||
"status",
|
||||
"mastered",
|
||||
"wikidata",
|
||||
]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlatformForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ["name", "group"]
|
||||
fields = [
|
||||
"name",
|
||||
"icon",
|
||||
"group",
|
||||
]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class DeviceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
mark_as_finished = forms.BooleanField(
|
||||
required=False,
|
||||
initial={"mark_as_finished": True},
|
||||
label="Set game status to Finished",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = ["game", "started", "ended", "note", "mark_as_finished"]
|
||||
widgets = {
|
||||
"started": custom_date_widget,
|
||||
"ended": custom_date_widget,
|
||||
}
|
||||
|
||||
def save(self, commit=True):
|
||||
with transaction.atomic():
|
||||
session = super().save(commit=False)
|
||||
if self.cleaned_data.get("mark_as_finished"):
|
||||
game_instance = session.game
|
||||
game_instance.status = "f"
|
||||
game_instance.save()
|
||||
session.save()
|
||||
return session
|
||||
|
||||
|
||||
class GameStatusChangeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = GameStatusChange
|
||||
fields = [
|
||||
"game",
|
||||
"old_status",
|
||||
"new_status",
|
||||
"timestamp",
|
||||
]
|
||||
widgets = {
|
||||
"timestamp": custom_datetime_widget,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages as django_messages
|
||||
from django.contrib.messages import constants as message_constants
|
||||
|
||||
MESSAGE_LEVEL_MAP = {
|
||||
message_constants.DEBUG: "debug",
|
||||
message_constants.INFO: "info",
|
||||
message_constants.SUCCESS: "success",
|
||||
message_constants.WARNING: "warning",
|
||||
message_constants.ERROR: "error",
|
||||
}
|
||||
|
||||
|
||||
class HTMXMessagesMiddleware:
|
||||
"""
|
||||
Converts Django messages into HX-Trigger headers so toasts display
|
||||
automatically without changes to views.
|
||||
|
||||
Works for HTMX requests (processed natively by HTMX client),
|
||||
vanilla fetch() calls using fetchWithHtmxTriggers(), and is harmless
|
||||
for full-page loads (browsers ignore HX-Trigger).
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Skip HX-Trigger and don't consume messages if there's an HX-Redirect
|
||||
# so the message persists in the session for the redirect target page
|
||||
if "HX-Redirect" in response:
|
||||
return response
|
||||
|
||||
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
backend = django_messages.get_messages(request)
|
||||
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
|
||||
backend._set_level(min_level)
|
||||
messages = list(backend)
|
||||
if not messages:
|
||||
return response
|
||||
|
||||
triggers = []
|
||||
for msg in messages:
|
||||
toast_type = MESSAGE_LEVEL_MAP.get(msg.level, "info")
|
||||
triggers.append(
|
||||
{
|
||||
"message": msg.message,
|
||||
"type": toast_type,
|
||||
}
|
||||
)
|
||||
|
||||
if triggers:
|
||||
# Use last message (most recent) as the primary toast
|
||||
trigger = triggers[-1]
|
||||
response["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": trigger,
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now
|
||||
from django_q.models import Schedule
|
||||
from django_q.tasks import schedule
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Manually schedule the next update_converted_prices task"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
schedule(
|
||||
"games.tasks.convert_prices",
|
||||
name="Update converted prices",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Scheduled the update_converted_prices task.")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Task is already scheduled."))
|
||||
@@ -1,5 +1,6 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 18:27
|
||||
# Generated by Django 5.1.5 on 2025-01-29 21:26
|
||||
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -8,94 +9,96 @@ class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
name='Device',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("wikidata", models.CharField(max_length=50)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Platform",
|
||||
name='Platform',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("group", models.CharField(max_length=255)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('icon', models.SlugField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Purchase",
|
||||
name='ExchangeRate',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date_purchased", models.DateField()),
|
||||
("date_refunded", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('currency_from', models.CharField(max_length=255)),
|
||||
('currency_to', models.CharField(max_length=255)),
|
||||
('year', models.PositiveIntegerField()),
|
||||
('rate', models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('currency_from', 'currency_to', 'year')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Game',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('year_released', models.IntegerField(blank=True, default=None, null=True)),
|
||||
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('name', 'platform', 'year_released')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Purchase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date_purchased', models.DateField()),
|
||||
('date_refunded', models.DateField(blank=True, null=True)),
|
||||
('date_finished', models.DateField(blank=True, null=True)),
|
||||
('date_dropped', models.DateField(blank=True, null=True)),
|
||||
('infinite', models.BooleanField(default=False)),
|
||||
('price', models.FloatField(default=0)),
|
||||
('price_currency', models.CharField(default='USD', max_length=3)),
|
||||
('converted_price', models.FloatField(null=True)),
|
||||
('converted_currency', models.CharField(max_length=3, null=True)),
|
||||
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
|
||||
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
|
||||
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
|
||||
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Session",
|
||||
name='Session',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("timestamp_start", models.DateTimeField()),
|
||||
("timestamp_end", models.DateTimeField()),
|
||||
("duration_manual", models.DurationField(blank=True, null=True)),
|
||||
("duration_calculated", models.DurationField(blank=True, null=True)),
|
||||
("note", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"purchase",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp_start', models.DateTimeField()),
|
||||
('timestamp_end', models.DateTimeField(blank=True, null=True)),
|
||||
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
|
||||
('duration_calculated', models.DurationField(blank=True, null=True)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('emulated', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('modified_at', models.DateTimeField(auto_now=True)),
|
||||
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
|
||||
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'timestamp_start',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 18:55
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 23:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0002_alter_session_duration_manual"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0002_purchase_price_per_game'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 14:49
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0003_alter_session_duration_manual_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:57
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
def initialize_num_purchases(apps, schema_editor):
|
||||
Purchase = apps.get_model("games", "Purchase")
|
||||
purchases = Purchase.objects.annotate(num_games=Count("games"))
|
||||
|
||||
for purchase in purchases:
|
||||
purchase.num_purchases = purchase.num_games
|
||||
purchase.save(update_fields=["num_purchases"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0003_purchase_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="num_purchases",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.RunPython(initialize_num_purchases),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 17:43
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_duration_calculated_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_calculated == None:
|
||||
session.duration_calculated = timedelta(0)
|
||||
session.save()
|
||||
|
||||
|
||||
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_calculated == timedelta(0):
|
||||
session.duration_calculated = None
|
||||
session.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0004_alter_session_duration_manual"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_duration_calculated_none_to_zero,
|
||||
revert_set_duration_calculated_none_to_zero,
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-01 19:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_finished_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0004_purchase_num_purchases"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="mastered",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
default="u",
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_finished_status),
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-01 12:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0005_game_mastered_game_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='game',
|
||||
name='sort_name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='game',
|
||||
name='wikidata',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='group',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='converted_currency',
|
||||
field=models.CharField(blank=True, default='', max_length=3),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='games',
|
||||
field=models.ManyToManyField(related_name='purchases', to='games.game'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='related_purchase',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='game',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='note',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 18:04
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_duration_manual_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_manual == None:
|
||||
session.duration_manual = timedelta(0)
|
||||
session.save()
|
||||
|
||||
|
||||
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_manual == timedelta(0):
|
||||
session.duration_manual = None
|
||||
session.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0005_auto_20230109_1843"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_duration_manual_none_to_zero,
|
||||
revert_set_duration_manual_none_to_zero,
|
||||
)
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-19 18:30
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0006_auto_20230109_1904"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="platform",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="purchase",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-17 07:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='game',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Edition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-19 13:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Min
|
||||
|
||||
|
||||
def copy_year_released(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.update(original_year_released=F("year_released"))
|
||||
|
||||
|
||||
def set_abandoned_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game = apps.get_model("games", "Game")
|
||||
PlayEvent = apps.get_model("games", "PlayEvent")
|
||||
|
||||
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
|
||||
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
|
||||
|
||||
finished = Game.objects.filter(purchases__date_finished__isnull=False)
|
||||
|
||||
for game in finished:
|
||||
for purchase in game.purchases.all():
|
||||
first_session = game.sessions.filter(
|
||||
timestamp_start__gte=purchase.date_purchased
|
||||
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
|
||||
first_session_date = first_session.date() if first_session else None
|
||||
if purchase.date_finished:
|
||||
play_event = PlayEvent(
|
||||
game=game,
|
||||
started=first_session_date
|
||||
if first_session_date
|
||||
else purchase.date_purchased,
|
||||
ended=purchase.date_finished,
|
||||
)
|
||||
play_event.save()
|
||||
|
||||
|
||||
def create_game_status_changes(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
GameStatusChange = apps.get_model("games", "GameStatusChange")
|
||||
|
||||
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
|
||||
for game in Game.objects.filter(sessions__isnull=False).distinct():
|
||||
if game.sessions.exists():
|
||||
earliest_session = game.sessions.earliest()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="u",
|
||||
new_status="p",
|
||||
timestamp=earliest_session.timestamp_start,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_dropped,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_refunded,
|
||||
)
|
||||
|
||||
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
|
||||
# consider only the first playevent
|
||||
for game in Game.objects.filter(playevents__isnull=False):
|
||||
first_playevent = game.playevents.first()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="f",
|
||||
timestamp=first_playevent.ended,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0007_game_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="original_year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.RunPython(copy_year_released),
|
||||
migrations.CreateModel(
|
||||
name="GameStatusChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"old_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"new_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
("timestamp", models.DateTimeField(null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="status_changes",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("started", models.DateField(blank=True, null=True)),
|
||||
("ended", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"days_to_finish",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.RawSQL(
|
||||
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
),
|
||||
),
|
||||
("note", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="playevents",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(set_abandoned_status),
|
||||
migrations.RunPython(create_game_status_changes),
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 18:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_edition_of_game(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
Platform = apps.get_model("games", "Platform")
|
||||
first_platform = Platform.objects.first()
|
||||
all_games = Game.objects.all()
|
||||
all_editions = Edition.objects.all()
|
||||
for game in all_games:
|
||||
existing_edition = None
|
||||
try:
|
||||
existing_edition = all_editions.objects.get(game=game.id)
|
||||
except:
|
||||
pass
|
||||
if existing_edition == None:
|
||||
edition = Edition()
|
||||
edition.id = game.id
|
||||
edition.game = game
|
||||
edition.name = game.name
|
||||
edition.platform = first_platform
|
||||
edition.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0008_edition"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(create_edition_of_game)]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-20 11:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_dropped',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_finished',
|
||||
),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:06
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0009_create_editions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0009_remove_purchase_date_dropped_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0010_remove_purchase_price_per_game'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0010_alter_purchase_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="purchase",
|
||||
old_name="game",
|
||||
new_name="edition",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:30
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0011_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.functions.comparison.Coalesce(
|
||||
django.db.models.expressions.CombinedExpression(
|
||||
models.F("timestamp_end"), "-", models.F("timestamp_start")
|
||||
),
|
||||
0,
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0011_rename_game_purchase_edition"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_currency",
|
||||
field=models.CharField(default="USD", max_length=3),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:33
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Sum
|
||||
|
||||
|
||||
def calculate_game_playtime(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
games = Game.objects.all()
|
||||
for game in games:
|
||||
total_playtime = game.sessions.aggregate(
|
||||
total_playtime=Sum(F("duration_total"))
|
||||
)["total_playtime"]
|
||||
if total_playtime:
|
||||
game.playtime = total_playtime
|
||||
game.save(update_fields=["playtime"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0012_alter_session_duration_calculated"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="playtime",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), editable=False
|
||||
),
|
||||
),
|
||||
migrations.RunPython(calculate_game_playtime),
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0012_purchase_price_purchase_price_currency"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="ownership_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0013_purchase_ownership_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pc", "PC"),
|
||||
("co", "Console"),
|
||||
("ha", "Handheld"),
|
||||
("mo", "Mobile"),
|
||||
("sbc", "Single-board computer"),
|
||||
],
|
||||
default="pc",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="device",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:46
|
||||
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0013_game_playtime'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='session',
|
||||
name='duration_total',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.1.7 on 2026-01-15 15:37
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0014_session_duration_total'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_purchased',
|
||||
field=models.DateField(verbose_name='Purchased'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_refunded',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='duration_manual',
|
||||
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_end',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(verbose_name='Start'),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-20 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0014_device_session_device"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="edition",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default=None, max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="edition",
|
||||
name="year_released",
|
||||
field=models.IntegerField(default=2023),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.1 on 2026-05-12 11:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0015_alter_purchase_date_purchased_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='needs_price_update',
|
||||
field=models.BooleanField(db_index=True, default=True),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE games_purchase SET needs_price_update = FALSE WHERE converted_price IS NOT NULL AND converted_currency != ''",
|
||||
reverse_sql="UPDATE games_purchase SET needs_price_update = TRUE WHERE converted_price IS NOT NULL AND converted_currency != ''",
|
||||
),
|
||||
]
|
||||
+401
-64
@@ -1,31 +1,139 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Q, Sum
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.fields.generated import GeneratedField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.template.defaultfilters import floatformat, pluralize, slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from common.time import format_duration
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import F, Manager, Sum
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
class Meta:
|
||||
unique_together = [["name", "platform", "year_released"]]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
wikidata = models.CharField(max_length=50)
|
||||
sort_name = models.CharField(max_length=255, blank=True, default="")
|
||||
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
original_year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
wikidata = models.CharField(max_length=50, blank=True, default="")
|
||||
platform = models.ForeignKey(
|
||||
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
||||
)
|
||||
|
||||
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Status(models.TextChoices):
|
||||
UNPLAYED = (
|
||||
"u",
|
||||
"Unplayed",
|
||||
)
|
||||
PLAYED = (
|
||||
"p",
|
||||
"Played",
|
||||
)
|
||||
FINISHED = (
|
||||
"f",
|
||||
"Finished",
|
||||
)
|
||||
RETIRED = (
|
||||
"r",
|
||||
"Retired",
|
||||
)
|
||||
ABANDONED = (
|
||||
"a",
|
||||
"Abandoned",
|
||||
)
|
||||
|
||||
status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED)
|
||||
mastered = models.BooleanField(default=False)
|
||||
|
||||
session_average: float | int | timedelta | None
|
||||
session_count: int | None
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def finished(self):
|
||||
return (self.status == self.Status.FINISHED or
|
||||
self.playevents.filter(ended__isnull=False).exists())
|
||||
|
||||
class Edition(models.Model):
|
||||
game = models.ForeignKey("Game", on_delete=models.CASCADE)
|
||||
def abandoned(self):
|
||||
return self.status == self.Status.ABANDONED
|
||||
|
||||
def retired(self):
|
||||
return self.status == self.Status.RETIRED
|
||||
|
||||
def played(self):
|
||||
return self.status == self.Status.PLAYED
|
||||
|
||||
def unplayed(self):
|
||||
return self.status == self.Status.UNPLAYED
|
||||
|
||||
def playtime_formatted(self):
|
||||
return format_duration(self.playtime, "%2.1H")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.platform is None:
|
||||
self.platform = get_sentinel_platform()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def get_sentinel_platform():
|
||||
return Platform.objects.get_or_create(
|
||||
name="Unspecified", icon="unspecified", group="Unspecified"
|
||||
)[0]
|
||||
|
||||
|
||||
class Platform(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
|
||||
year_released = models.IntegerField(default=datetime.today().year)
|
||||
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||
group = models.CharField(max_length=255, blank=True, default="")
|
||||
icon = models.SlugField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.icon:
|
||||
self.icon = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PurchaseQueryset(models.QuerySet):
|
||||
def refunded(self):
|
||||
return self.filter(date_refunded__isnull=False)
|
||||
|
||||
def not_refunded(self):
|
||||
return self.filter(date_refunded__isnull=True)
|
||||
|
||||
def games_only(self):
|
||||
return self.filter(type=Purchase.GAME)
|
||||
|
||||
def finished(self):
|
||||
return self.filter(
|
||||
Q(games__status="f") | Q(games__playevents__ended__isnull=False)
|
||||
).distinct()
|
||||
|
||||
def abandoned(self):
|
||||
return self.filter(games__status="a").distinct()
|
||||
|
||||
def dropped(self):
|
||||
return self.filter(
|
||||
Q(games__status="a") | Q(date_refunded__isnull=False)
|
||||
).distinct()
|
||||
|
||||
|
||||
class Purchase(models.Model):
|
||||
PHYSICAL = "ph"
|
||||
@@ -46,101 +154,330 @@ class Purchase(models.Model):
|
||||
(DEMO, "Demo"),
|
||||
(PIRATED, "Pirated"),
|
||||
]
|
||||
GAME = "game"
|
||||
DLC = "dlc"
|
||||
SEASONPASS = "season_pass"
|
||||
BATTLEPASS = "battle_pass"
|
||||
TYPES = [
|
||||
(GAME, "Game"),
|
||||
(DLC, "DLC"),
|
||||
(SEASONPASS, "Season Pass"),
|
||||
(BATTLEPASS, "Battle Pass"),
|
||||
]
|
||||
|
||||
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
|
||||
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
|
||||
date_purchased = models.DateField()
|
||||
date_refunded = models.DateField(blank=True, null=True)
|
||||
price = models.IntegerField(default=0)
|
||||
objects = PurchaseQueryset().as_manager()
|
||||
|
||||
games = models.ManyToManyField(Game, related_name="purchases")
|
||||
|
||||
platform = models.ForeignKey(
|
||||
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
|
||||
)
|
||||
date_purchased = models.DateField(verbose_name="Purchased")
|
||||
date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
|
||||
infinite = models.BooleanField(default=False)
|
||||
price = models.FloatField(default=0)
|
||||
price_currency = models.CharField(max_length=3, default="USD")
|
||||
converted_price = models.FloatField(null=True)
|
||||
converted_currency = models.CharField(max_length=3, blank=True, default="")
|
||||
needs_price_update = models.BooleanField(default=True, db_index=True)
|
||||
price_per_game = GeneratedField(
|
||||
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
||||
output_field=models.FloatField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
num_purchases = models.IntegerField(default=0)
|
||||
ownership_type = models.CharField(
|
||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||
)
|
||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||
name = models.CharField(max_length=255, blank=True, default="")
|
||||
related_purchase = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
default=None,
|
||||
null=True,
|
||||
related_name="related_purchases",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def standardized_price(self):
|
||||
return (
|
||||
f"{floatformat(self.converted_price, 0)} {self.converted_currency}"
|
||||
if self.converted_price
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def has_one_item(self):
|
||||
return self.games.count() == 1
|
||||
|
||||
@property
|
||||
def standardized_name(self):
|
||||
return self.name or self.first_game.name
|
||||
|
||||
@property
|
||||
def first_game(self):
|
||||
return self.games.first()
|
||||
|
||||
def __str__(self):
|
||||
platform_info = self.platform
|
||||
if self.platform != self.edition.platform:
|
||||
platform_info = f"{self.edition.platform} version on {self.platform}"
|
||||
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
|
||||
return self.standardized_name
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
additional_info = [
|
||||
str(item)
|
||||
for item in [
|
||||
f"{self.num_purchases} game{pluralize(self.num_purchases)}",
|
||||
self.date_purchased,
|
||||
self.standardized_price,
|
||||
]
|
||||
if item
|
||||
]
|
||||
return f"{self.standardized_name} ({', '.join(additional_info)})"
|
||||
|
||||
class Platform(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
group = models.CharField(max_length=255)
|
||||
def is_game(self):
|
||||
return self.type == self.GAME
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
def refund(self):
|
||||
self.date_refunded = timezone.now()
|
||||
self.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.type != Purchase.GAME and not self.related_purchase:
|
||||
raise ValidationError(
|
||||
f"{self.get_type_display()} must have a related purchase."
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SessionQuerySet(models.QuerySet):
|
||||
def total_duration(self):
|
||||
def total_duration_formatted(self):
|
||||
return format_duration(self.total_duration_unformatted())
|
||||
|
||||
def total_duration_unformatted(self):
|
||||
result = self.aggregate(
|
||||
duration=Sum(F("duration_calculated") + F("duration_manual"))
|
||||
)
|
||||
return format_duration(result["duration"])
|
||||
return result["duration"]
|
||||
|
||||
def calculated_duration_formatted(self):
|
||||
return format_duration(self.calculated_duration_unformatted())
|
||||
|
||||
def calculated_duration_unformatted(self):
|
||||
result = self.aggregate(duration=Sum(F("duration_calculated")))
|
||||
return result["duration"]
|
||||
|
||||
def without_manual(self):
|
||||
return self.exclude(duration_calculated__iexact=0)
|
||||
|
||||
def only_manual(self):
|
||||
return self.filter(duration_calculated__iexact=0)
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
|
||||
timestamp_start = models.DateTimeField()
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True)
|
||||
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
|
||||
duration_calculated = models.DurationField(blank=True, null=True)
|
||||
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
class Meta:
|
||||
get_latest_by = "timestamp_start"
|
||||
|
||||
game = models.ForeignKey(
|
||||
Game,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
default=None,
|
||||
related_name="sessions",
|
||||
)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start")
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
|
||||
duration_manual = models.DurationField(
|
||||
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
|
||||
)
|
||||
duration_calculated = GeneratedField(
|
||||
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
|
||||
output_field=models.DurationField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
duration_total = GeneratedField(
|
||||
expression=F("duration_calculated") + F("duration_manual"),
|
||||
output_field=models.DurationField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
"Device",
|
||||
on_delete=models.SET_DEFAULT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
)
|
||||
note = models.TextField(blank=True, default="")
|
||||
emulated = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
modified_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = SessionQuerySet.as_manager()
|
||||
|
||||
def __str__(self):
|
||||
mark = ", manual" if self.duration_manual != None else ""
|
||||
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
mark = "*" if self.is_manual() else ""
|
||||
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
|
||||
def finish_now(self):
|
||||
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
self.timestamp_end = timezone.now()
|
||||
|
||||
def start_now():
|
||||
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
|
||||
def duration_seconds(self) -> timedelta:
|
||||
manual = timedelta(0)
|
||||
calculated = timedelta(0)
|
||||
if not self.duration_manual in (None, 0, timedelta(0)):
|
||||
manual = self.duration_manual
|
||||
if self.timestamp_end != None and self.timestamp_start != None:
|
||||
calculated = self.timestamp_end - self.timestamp_start
|
||||
return timedelta(seconds=(manual + calculated).total_seconds())
|
||||
self.timestamp_start = timezone.now()
|
||||
|
||||
def duration_formatted(self) -> str:
|
||||
result = format_duration(self.duration_seconds(), "%H:%m")
|
||||
result = format_duration(self.duration_total, "%02.1H")
|
||||
return result
|
||||
|
||||
@property
|
||||
def duration_sum(self) -> str:
|
||||
return Session.objects.all().total_duration()
|
||||
def duration_formatted_with_mark(self) -> str:
|
||||
mark = "*" if self.is_manual() else ""
|
||||
return f"{self.duration_formatted()}{mark}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.timestamp_start != None and self.timestamp_end != None:
|
||||
self.duration_calculated = self.timestamp_end - self.timestamp_start
|
||||
else:
|
||||
self.duration_calculated = timedelta(0)
|
||||
def is_manual(self) -> bool:
|
||||
return not self.duration_manual == timedelta(0)
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not isinstance(self.duration_manual, timedelta):
|
||||
self.duration_manual = timedelta(0)
|
||||
|
||||
if not self.device:
|
||||
default_device, _ = Device.objects.get_or_create(
|
||||
type=Device.UNKNOWN, defaults={"name": "Unknown"}
|
||||
)
|
||||
self.device = default_device
|
||||
super(Session, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
PC = "pc"
|
||||
CONSOLE = "co"
|
||||
HANDHELD = "ha"
|
||||
MOBILE = "mo"
|
||||
SBC = "sbc"
|
||||
PC = "PC"
|
||||
CONSOLE = "Console"
|
||||
HANDHELD = "Handheld"
|
||||
MOBILE = "Mobile"
|
||||
SBC = "Single-board computer"
|
||||
UNKNOWN = "Unknown"
|
||||
DEVICE_TYPES = [
|
||||
(PC, "PC"),
|
||||
(CONSOLE, "Console"),
|
||||
(HANDHELD, "Handheld"),
|
||||
(MOBILE, "Mobile"),
|
||||
(SBC, "Single-board computer"),
|
||||
(UNKNOWN, "Unknown"),
|
||||
]
|
||||
name = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
|
||||
type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_type_display()})"
|
||||
return f"{self.name} ({self.type})"
|
||||
|
||||
|
||||
class ExchangeRate(models.Model):
|
||||
currency_from = models.CharField(max_length=255)
|
||||
currency_to = models.CharField(max_length=255)
|
||||
year = models.PositiveIntegerField()
|
||||
rate = models.FloatField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ("currency_from", "currency_to", "year")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
|
||||
|
||||
|
||||
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
|
||||
exchange_rate = None
|
||||
result = ExchangeRate.objects.filter(
|
||||
currency_from=currency_from, currency_to=currency_to, year=year
|
||||
)
|
||||
if result:
|
||||
exchange_rate = result[0].rate
|
||||
else:
|
||||
try:
|
||||
# this API endpoint only accepts lowercase currency string
|
||||
response = requests.get(
|
||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
currency_from_data = data.get(currency_from.lower())
|
||||
rate = currency_from_data.get(currency_to.lower())
|
||||
|
||||
if rate:
|
||||
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
||||
exchange_rate = ExchangeRate.objects.create(
|
||||
currency_from=currency_from,
|
||||
currency_to=currency_to,
|
||||
year=year,
|
||||
rate=floatformat(rate, 2),
|
||||
)
|
||||
exchange_rate = exchange_rate.rate
|
||||
else:
|
||||
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||
except requests.RequestException as e:
|
||||
logger.info(
|
||||
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||
)
|
||||
return exchange_rate
|
||||
|
||||
|
||||
class PlayEvent(models.Model):
|
||||
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
|
||||
started = models.DateField(null=True, blank=True)
|
||||
ended = models.DateField(null=True, blank=True)
|
||||
days_to_finish = GeneratedField(
|
||||
# special cases:
|
||||
# missing ended, started, or both = 0
|
||||
# same day = 1 day to finish
|
||||
expression=RawSQL(
|
||||
"""
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN date(ended) = date(started) THEN 1
|
||||
ELSE julianday(ended) - julianday(started)
|
||||
END, 0
|
||||
)
|
||||
""",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
blank=True,
|
||||
)
|
||||
note = models.CharField(max_length=255, blank=True, default="")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
# class PlayMarker(models.Model):
|
||||
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
|
||||
# played_since = models.DurationField()
|
||||
# played_total = models.DurationField()
|
||||
# note = models.CharField(max_length=255)
|
||||
|
||||
|
||||
class GameStatusChange(models.Model):
|
||||
"""
|
||||
Tracks changes to the status of a Game.
|
||||
"""
|
||||
|
||||
game = models.ForeignKey(
|
||||
Game, on_delete=models.CASCADE, related_name="status_changes"
|
||||
)
|
||||
old_status = models.CharField(
|
||||
max_length=1, choices=Game.Status.choices, blank=True, null=True
|
||||
)
|
||||
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
|
||||
timestamp = models.DateTimeField(null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-timestamp"]
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.signals import (
|
||||
m2m_changed,
|
||||
post_delete,
|
||||
post_save,
|
||||
pre_delete,
|
||||
pre_save,
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from games.models import Game, GameStatusChange, Purchase, Session
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Purchase)
|
||||
def store_purchase_price_snapshot(sender, instance, **kwargs):
|
||||
"""Store old price values before save so we can detect changes."""
|
||||
if instance.pk is not None:
|
||||
try:
|
||||
old_instance = sender.objects.get(pk=instance.pk)
|
||||
instance._old_price = old_instance.price
|
||||
instance._old_currency = old_instance.price_currency
|
||||
except sender.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
@receiver(post_save, sender=Purchase)
|
||||
def mark_needs_price_update(sender, instance, created, **kwargs):
|
||||
"""Mark purchase for price update if price or currency changed."""
|
||||
if not created and hasattr(instance, "_old_price"):
|
||||
if (
|
||||
instance.price != instance._old_price
|
||||
or instance.price_currency != instance._old_currency
|
||||
):
|
||||
sender.objects.filter(pk=instance.pk).update(needs_price_update=True)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Purchase.games.through)
|
||||
def update_num_purchases(sender, instance, action, reverse, **kwargs):
|
||||
if not reverse and action.startswith("post_"):
|
||||
instance.num_purchases = instance.games.count()
|
||||
instance.updated_at = now()
|
||||
instance.save(update_fields=["num_purchases", "updated_at"])
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Game)
|
||||
def update_purchase_counts_on_game_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Update num_purchases on related Purchase objects when a Game is deleted.
|
||||
m2m_changed is not fired when a related object is deleted.
|
||||
"""
|
||||
for purchase in instance.purchases.all():
|
||||
if purchase.num_purchases > 0:
|
||||
purchase.num_purchases -= 1
|
||||
if purchase.num_purchases == 0:
|
||||
purchase.delete()
|
||||
else:
|
||||
purchase.updated_at = now()
|
||||
purchase.save(update_fields=["num_purchases", "updated_at"])
|
||||
|
||||
|
||||
@receiver([post_save, post_delete], sender=Session)
|
||||
def update_game_playtime(sender, instance, **kwargs):
|
||||
# During cascade deletes the related Game may already have been removed.
|
||||
# Use the FK id to look up the Game safely and bail out if it no longer exists.
|
||||
game_pk = getattr(instance, "game_id", None)
|
||||
if not game_pk:
|
||||
return
|
||||
game = Game.objects.filter(pk=game_pk).first()
|
||||
if not game:
|
||||
return
|
||||
|
||||
total_playtime = game.sessions.aggregate(
|
||||
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
|
||||
)["total_playtime"]
|
||||
game.playtime = total_playtime if total_playtime else timedelta(0)
|
||||
game.save(update_fields=["playtime"])
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Game)
|
||||
def game_status_changed(sender, instance, **kwargs):
|
||||
"""
|
||||
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
|
||||
"""
|
||||
try:
|
||||
old_instance = sender.objects.get(pk=instance.pk)
|
||||
old_status = old_instance.status
|
||||
logger.info("[game_status_changed]: Previous status exists.")
|
||||
except sender.DoesNotExist:
|
||||
# Handle the case where the instance was deleted before the signal was sent
|
||||
logger.info("[game_status_changed]: Previous status does not exist.")
|
||||
return
|
||||
|
||||
if old_status != instance.status:
|
||||
logger.info(
|
||||
"[game_status_changed]: Status changed from {} to {}".format(
|
||||
old_status, instance.status
|
||||
)
|
||||
)
|
||||
GameStatusChange.objects.create(
|
||||
game=instance,
|
||||
old_status=old_status,
|
||||
new_status=instance.status,
|
||||
timestamp=now(),
|
||||
)
|
||||
else:
|
||||
logger.info("[game_status_changed]: Status has not changed")
|
||||
+5086
-5332
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 787 B |
Binary file not shown.
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1,24 @@
|
||||
import { syncSelectInputUntilChanged } from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_game",
|
||||
source_value: "dataset.name",
|
||||
target: "#id_name",
|
||||
target_value: "value",
|
||||
},
|
||||
{
|
||||
source: "#id_game",
|
||||
source_value: "textContent",
|
||||
target: "#id_sort_name",
|
||||
target_value: "value",
|
||||
},
|
||||
{
|
||||
source: "#id_game",
|
||||
source_value: "dataset.year",
|
||||
target: "#id_year_released",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
@@ -0,0 +1,12 @@
|
||||
import { syncSelectInputUntilChanged } from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_name",
|
||||
source_value: "value",
|
||||
target: "#id_sort_name",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenValueNotEqual,
|
||||
} from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_games",
|
||||
source_value: "dataset.platform",
|
||||
target: "#id_platform",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
|
||||
function setupElementHandlers() {
|
||||
disableElementsWhenTrue("#id_type", "game", [
|
||||
"#id_name",
|
||||
"#id_related_purchase",
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { toISOUTCString } from "./utils.js";
|
||||
|
||||
for (let button of document.querySelectorAll("[data-target]")) {
|
||||
let target = button.getAttribute("data-target");
|
||||
let type = button.getAttribute("data-type");
|
||||
let targetElement = document.querySelector(`#id_${target}`);
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (type == "now") {
|
||||
targetElement.value = toISOUTCString(new Date());
|
||||
} else if (type == "copy") {
|
||||
const oppositeName =
|
||||
targetElement.name == "timestamp_start"
|
||||
? "timestamp_end"
|
||||
: "timestamp_start";
|
||||
document.querySelector(`[name='${oppositeName}']`).value =
|
||||
targetElement.value;
|
||||
} else if (type == "toggle") {
|
||||
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
||||
else targetElement.type = "datetime-local";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
(function() {
|
||||
htmx.defineExtension("hx-redirect-toast", {
|
||||
isInlineSwap: function(swapStyle) {
|
||||
return swapStyle === "hx-redirect-toast";
|
||||
},
|
||||
handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
|
||||
var xhr = htmxConfig.xhr;
|
||||
var hxRedirect = xhr.getResponseHeader("HX-Redirect");
|
||||
var hxTrigger = xhr.getResponseHeader("HX-Trigger");
|
||||
|
||||
// Redirect immediately (toast will be shown on the new page)
|
||||
if (hxRedirect) {
|
||||
window.location.href = hxRedirect;
|
||||
}
|
||||
|
||||
// Only dispatch HX-Trigger events for toasts when not redirecting
|
||||
if (!hxRedirect && hxTrigger) {
|
||||
var triggers = JSON.parse(hxTrigger);
|
||||
var events = Array.isArray(triggers) ? triggers : [triggers];
|
||||
events.forEach(function(triggerObj) {
|
||||
Object.entries(triggerObj).forEach(function(entry) {
|
||||
var name = entry[0];
|
||||
var detail = entry[1];
|
||||
try { detail = JSON.parse(detail); } catch(e) {}
|
||||
target.dispatchEvent(new CustomEvent(name, {
|
||||
detail: detail,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
// Return null to prevent any DOM swap
|
||||
return null;
|
||||
}
|
||||
});
|
||||
})();
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,173 @@
|
||||
document.addEventListener("alpine:init", () => {
|
||||
let idCounter = 0;
|
||||
|
||||
console.log("[toast] Alpine available:", typeof Alpine !== "undefined");
|
||||
|
||||
Alpine.store("toasts", {
|
||||
toasts: [],
|
||||
|
||||
addToast(message, type) {
|
||||
console.log("[toast] addToast called:", { message, type });
|
||||
if (!type) type = "info";
|
||||
const validTypes = ["success", "error", "info", "warning", "debug"];
|
||||
if (!validTypes.includes(type)) type = "info";
|
||||
|
||||
if (this.toasts.length >= 3) {
|
||||
console.log("[toast] max 3 toasts reached, removing oldest");
|
||||
this.toasts.shift();
|
||||
}
|
||||
|
||||
const id = ++idCounter;
|
||||
console.log("[toast] toast added, count:", this.toasts.length);
|
||||
this.toasts.push({ id, message, type, visible: true, timer: null, pausedAt: null });
|
||||
|
||||
if (type !== "error") {
|
||||
const toast = this.toasts[this.toasts.length - 1];
|
||||
const autoDismissDelay = type === "debug" ? 3000 : 5000;
|
||||
toast.timer = setTimeout(() => {
|
||||
console.log("[toast] auto-dismiss after " + (autoDismissDelay / 1000) + "s");
|
||||
this.dismissToast(id);
|
||||
}, autoDismissDelay);
|
||||
}
|
||||
},
|
||||
|
||||
dismissToast(id) {
|
||||
console.log("[toast] dismissToast for id:", id);
|
||||
const idx = this.toasts.findIndex((t) => t.id === id);
|
||||
if (idx === -1) { console.log("[toast] toast not found"); return; }
|
||||
|
||||
const toast = this.toasts[idx];
|
||||
if (toast.timer) clearTimeout(toast.timer);
|
||||
toast.visible = false;
|
||||
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
console.log("[toast] after dismiss, count:", this.toasts.length);
|
||||
}, 300);
|
||||
},
|
||||
|
||||
clearToastTimer(id) {
|
||||
const toast = this.toasts.find((t) => t.id === id);
|
||||
if (toast?.timer) {
|
||||
console.log("[toast] pause timer for toast id:", id);
|
||||
clearTimeout(toast.timer);
|
||||
toast.timer = null;
|
||||
toast.pausedAt = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
resumeToastTimer(id, duration) {
|
||||
const toast = this.toasts.find((t) => t.id === id);
|
||||
if (toast?.pausedAt && toast.timer === null) {
|
||||
console.log("[toast] resume timer for toast id:", id);
|
||||
toast.timer = setTimeout(() => {
|
||||
this.dismissToast(id);
|
||||
}, duration);
|
||||
toast.pausedAt = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Alpine.data("toastStore", () => ({
|
||||
init() {
|
||||
console.log("[toast] toastStore.init running");
|
||||
console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts);
|
||||
|
||||
window.addEventListener("show-toast", (e) => {
|
||||
console.log("[toast] show-toast event received:", e.detail);
|
||||
if (Array.isArray(e.detail)) {
|
||||
e.detail.forEach((msg) => {
|
||||
Alpine.store("toasts").addToast(msg.message, msg.type);
|
||||
});
|
||||
} else {
|
||||
Alpine.store("toasts").addToast(e.detail.message, e.detail.type);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const script = document.getElementById("django-messages");
|
||||
if (script) {
|
||||
const msgs = JSON.parse(
|
||||
script.textContent || script.innerText || "[]"
|
||||
);
|
||||
console.log("[toast] django-messages script found:", msgs);
|
||||
if (Array.isArray(msgs)) {
|
||||
msgs.forEach((msg) => {
|
||||
console.log("[toast] loading django-message:", msg);
|
||||
Alpine.store("toasts").addToast(msg.message, msg.type || "info");
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[toast] localStorage restore failed:", e);
|
||||
// ignore parse errors
|
||||
}
|
||||
},
|
||||
|
||||
addToast(message, type) {
|
||||
console.log("[toast] toastStore.addToast delegating:", { message, type });
|
||||
Alpine.store("toasts").addToast(message, type);
|
||||
},
|
||||
|
||||
dismissToast(id) {
|
||||
console.log("[toast] toastStore.dismissToast delegating:", id);
|
||||
Alpine.store("toasts").dismissToast(id);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
function toast(message, type) {
|
||||
console.log("[toast] toast() called:", { message, type });
|
||||
const evt = new CustomEvent("show-toast", {
|
||||
detail: { message, type },
|
||||
bubbles: true,
|
||||
});
|
||||
document.dispatchEvent(evt);
|
||||
console.log("[toast] CustomEvent dispatched, type:", evt.type);
|
||||
}
|
||||
window.toast = toast;
|
||||
|
||||
/**
|
||||
* Wrapper around fetch() that dispatches HTMX HX-Trigger events.
|
||||
* Use this for any fetch() call that expects HX-Trigger headers
|
||||
* (e.g., to show toasts via the HTMX middleware).
|
||||
*
|
||||
* @todo Migrate these call sites to hx-post + hx-on::after-request
|
||||
* for HTMX-native toast handling.
|
||||
*/
|
||||
window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {}) {
|
||||
console.log("[fetchWithHtmxTriggers] fetching:", url);
|
||||
return fetch(url, options).then(async (response) => {
|
||||
console.log("[fetchWithHtmxTriggers] response status:", response.status);
|
||||
const htmxTrigger = response.headers.get("HX-Trigger");
|
||||
console.log("[fetchWithHtmxTriggers] HX-Trigger header:", htmxTrigger);
|
||||
if (htmxTrigger) {
|
||||
let triggers;
|
||||
try {
|
||||
triggers = JSON.parse(htmxTrigger);
|
||||
console.log("[fetchWithHtmxTriggers] parsed triggers:", triggers);
|
||||
} catch {
|
||||
console.warn("[fetchWithHtmxTriggers] failed to parse HX-Trigger JSON");
|
||||
return response;
|
||||
}
|
||||
// Handle both single object and array of events
|
||||
const events = Array.isArray(triggers) ? triggers : [triggers];
|
||||
events.forEach((triggerObj) => {
|
||||
Object.entries(triggerObj).forEach(([name, detail]) => {
|
||||
console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail);
|
||||
let parsedDetail = detail;
|
||||
try {
|
||||
parsedDetail = JSON.parse(detail);
|
||||
} catch {
|
||||
// keep as string
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent(name, {
|
||||
detail: parsedDetail,
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||
* @param {Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
function toISOUTCString(date) {
|
||||
function stringAndPad(number) {
|
||||
return number.toString().padStart(2, 0);
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = stringAndPad(date.getMonth() + 1);
|
||||
const day = stringAndPad(date.getDate());
|
||||
const hours = stringAndPad(date.getHours());
|
||||
const minutes = stringAndPad(date.getMinutes());
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Sync values between source and target elements based on syncData configuration.
|
||||
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
|
||||
*/
|
||||
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
|
||||
const parentElement =
|
||||
parentSelector === document
|
||||
? document
|
||||
: document.querySelector(parentSelector);
|
||||
|
||||
if (!parentElement) {
|
||||
console.error(`The parent selector "${parentSelector}" is not valid.`);
|
||||
return;
|
||||
}
|
||||
// Set up a single change event listener on the document for handling all source changes
|
||||
parentElement.addEventListener("change", function (event) {
|
||||
// Loop through each sync configuration item
|
||||
syncData.forEach((syncItem) => {
|
||||
// Check if the change event target matches the source selector
|
||||
if (event.target.matches(syncItem.source)) {
|
||||
const sourceElement = event.target;
|
||||
const valueToSync = getValueFromProperty(
|
||||
sourceElement,
|
||||
syncItem.source_value
|
||||
);
|
||||
const targetElement = document.querySelector(syncItem.target);
|
||||
|
||||
if (targetElement && valueToSync !== null) {
|
||||
console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
|
||||
targetElement[syncItem.target_value] = valueToSync;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set up a single focus event listener on the document for handling all target focuses
|
||||
parentElement.addEventListener(
|
||||
"focus",
|
||||
function (event) {
|
||||
// Loop through each sync configuration item
|
||||
syncData.forEach((syncItem) => {
|
||||
// Check if the focus event target matches the target selector
|
||||
if (event.target.matches(syncItem.target)) {
|
||||
// Remove the change event listener to stop syncing
|
||||
// This assumes you want to stop syncing once any target receives focus
|
||||
// You may need a more sophisticated way to remove listeners if you want to stop
|
||||
// syncing selectively based on other conditions
|
||||
document.removeEventListener("change", syncSelectInputUntilChanged);
|
||||
}
|
||||
});
|
||||
},
|
||||
true
|
||||
); // Use capture phase to ensure the event is captured during focus, not bubble
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieve the value from the source element based on the provided property.
|
||||
* @param {Element} sourceElement - The source HTML element.
|
||||
* @param {string} property - The property to retrieve the value from.
|
||||
*/
|
||||
function getValueFromProperty(sourceElement, property) {
|
||||
let source =
|
||||
sourceElement instanceof HTMLSelectElement
|
||||
? sourceElement.selectedOptions[0]
|
||||
: sourceElement;
|
||||
if (property.startsWith("dataset.")) {
|
||||
let datasetKey = property.slice(8); // Remove 'dataset.' part
|
||||
return source.dataset[datasetKey];
|
||||
} else if (property in source) {
|
||||
return source[property];
|
||||
} else {
|
||||
console.error(`Property ${property} is not valid for the option element.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Returns a single element by name.
|
||||
* @param {string} selector The selector to look for.
|
||||
*/
|
||||
function getEl(selector) {
|
||||
if (selector.startsWith("#")) {
|
||||
return document.getElementById(selector.slice(1));
|
||||
} else if (selector.startsWith(".")) {
|
||||
return document.getElementsByClassName(selector);
|
||||
} else {
|
||||
return document.getElementsByTagName(selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Applies different behaviors to elements based on multiple conditional configurations.
|
||||
* Each configuration is an array containing a condition function, an array of target element selectors,
|
||||
* and two callback functions for handling matched and unmatched conditions.
|
||||
* @param {...Array} configs Each configuration is an array of the form:
|
||||
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
|
||||
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
|
||||
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
|
||||
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
|
||||
*/
|
||||
function conditionalElementHandler(...configs) {
|
||||
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
|
||||
if (condition()) {
|
||||
targetElements.forEach((elementName) => {
|
||||
let el = getEl(elementName);
|
||||
if (el === null) {
|
||||
console.error(`Element ${elementName} doesn't exist.`);
|
||||
} else {
|
||||
callbackfn1(el);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
targetElements.forEach((elementName) => {
|
||||
let el = getEl(elementName);
|
||||
if (el === null) {
|
||||
console.error(`Element ${elementName} doesn't exist.`);
|
||||
} else {
|
||||
callbackfn2(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function disableElementsWhenValueNotEqual(
|
||||
targetSelect,
|
||||
targetValue,
|
||||
elementList
|
||||
) {
|
||||
return conditionalElementHandler([
|
||||
() => {
|
||||
let target = getEl(targetSelect);
|
||||
console.debug(
|
||||
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
|
||||
);
|
||||
console.debug(`
|
||||
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
|
||||
if (targetValue instanceof Array) {
|
||||
if (targetValue.every((value) => target.value != value)) {
|
||||
console.debug(
|
||||
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
console.debug(
|
||||
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
|
||||
);
|
||||
return target.value != targetValue;
|
||||
}
|
||||
},
|
||||
elementList,
|
||||
(el) => {
|
||||
console.debug(
|
||||
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
|
||||
);
|
||||
el.disabled = "disabled";
|
||||
},
|
||||
(el) => {
|
||||
console.debug(
|
||||
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
|
||||
);
|
||||
el.disabled = "";
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||
return conditionalElementHandler([
|
||||
() => {
|
||||
console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
|
||||
console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
|
||||
return getEl(targetSelect).value == targetValue;
|
||||
},
|
||||
elementList,
|
||||
(el) => {
|
||||
console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
|
||||
el.disabled = "disabled";
|
||||
},
|
||||
(el) => {
|
||||
console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
|
||||
el.disabled = "";
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export {
|
||||
toISOUTCString,
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
conditionalElementHandler,
|
||||
disableElementsWhenValueNotEqual,
|
||||
disableElementsWhenTrue,
|
||||
getValueFromProperty,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
function elt(type, props, ...children) {
|
||||
let dom = document.createElement(type);
|
||||
if (props) Object.assign(dom, props);
|
||||
for (let child of children) {
|
||||
if (typeof child != "string") dom.appendChild(child);
|
||||
else dom.appendChild(document.createTextNode(child));
|
||||
}
|
||||
return dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} targetNode
|
||||
*/
|
||||
function addToggleButton(targetNode) {
|
||||
let manualToggleButton = elt(
|
||||
"td",
|
||||
{},
|
||||
elt(
|
||||
"div",
|
||||
{ className: "basic-button" },
|
||||
elt(
|
||||
"button",
|
||||
{
|
||||
onclick: (event) => {
|
||||
let textInputField = elt("input", { type: "text", id: targetNode.id });
|
||||
targetNode.replaceWith(textInputField);
|
||||
event.target.addEventListener("click", (event) => {
|
||||
textInputField.replaceWith(targetNode);
|
||||
});
|
||||
},
|
||||
},
|
||||
"Toggle manual"
|
||||
)
|
||||
)
|
||||
);
|
||||
targetNode.parentElement.appendChild(manualToggleButton);
|
||||
}
|
||||
|
||||
const toggleableFields = ["#id_games", "#id_platform"];
|
||||
|
||||
toggleableFields.map((selector) => {
|
||||
addToggleButton(document.querySelector(selector));
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import floatformat
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
# fixme: save preferred currency in user model
|
||||
currency_to = "CZK"
|
||||
currency_to = currency_to.upper()
|
||||
|
||||
|
||||
def _get_exchange_rate(currency_from, currency_to, year):
|
||||
logger.debug(
|
||||
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
|
||||
)
|
||||
rate = ExchangeRate.objects.filter(
|
||||
currency_from=currency_from, currency_to=currency_to, year=year
|
||||
).first()
|
||||
if not rate:
|
||||
logger.debug(
|
||||
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
||||
)
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
currency_from_data = data.get(currency_from.lower())
|
||||
rate = currency_from_data.get(currency_to.lower())
|
||||
if rate:
|
||||
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
||||
exchange_rate = ExchangeRate.objects.create(
|
||||
currency_from=currency_from,
|
||||
currency_to=currency_to,
|
||||
year=year,
|
||||
rate=floatformat(rate, 2),
|
||||
)
|
||||
rate = exchange_rate.rate
|
||||
else:
|
||||
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||
except requests.RequestException as e:
|
||||
logger.info(
|
||||
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||
)
|
||||
elif rate:
|
||||
rate = rate.rate
|
||||
return rate
|
||||
|
||||
|
||||
def _save_converted_price(purchase, converted_price, needs_update):
|
||||
logger.info(
|
||||
f"Setting converted price of {purchase} to {converted_price} {currency_to} (originally {purchase.price} {purchase.price_currency})"
|
||||
)
|
||||
purchase.converted_price = converted_price
|
||||
purchase.converted_currency = currency_to
|
||||
if needs_update:
|
||||
purchase.needs_price_update = False
|
||||
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
|
||||
|
||||
|
||||
def convert_prices():
|
||||
purchases = Purchase.objects.filter(
|
||||
models.Q(needs_price_update=True) | models.Q(converted_price__isnull=True)
|
||||
).distinct()
|
||||
if purchases.count() == 0:
|
||||
logger.info("[convert_prices]: No prices to convert.")
|
||||
return
|
||||
|
||||
for purchase in purchases:
|
||||
needs_update = purchase.needs_price_update
|
||||
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
|
||||
_save_converted_price(purchase, purchase.price, needs_update)
|
||||
continue
|
||||
year = purchase.date_purchased.year
|
||||
currency_from = purchase.price_currency.upper()
|
||||
rate = _get_exchange_rate(currency_from, currency_to, year)
|
||||
if rate:
|
||||
_save_converted_price(
|
||||
purchase,
|
||||
floatformat(purchase.price * rate, 0),
|
||||
needs_update,
|
||||
)
|
||||
|
||||
|
||||
def calculate_price_per_game():
|
||||
"""
|
||||
This task is deprecated because price_per_game is now a GeneratedField.
|
||||
It is kept here to prevent errors from lingering scheduled tasks.
|
||||
"""
|
||||
try:
|
||||
from django_q.models import Schedule
|
||||
|
||||
Schedule.objects.filter(func="games.tasks.calculate_price_per_game").delete()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,16 +1,2 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<table class="mx-auto">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_table }}
|
||||
<tr>
|
||||
<td><input type="submit" value="Submit"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
<c-layouts.add>
|
||||
</c-layouts.add>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<c-layouts.add>
|
||||
<c-slot name="additional_row">
|
||||
<c-button type="submit" color="gray"
|
||||
name="submit_and_redirect"
|
||||
>
|
||||
Submit & Create Purchase
|
||||
</c-button>
|
||||
</c-slot>
|
||||
</c-layouts.add>
|
||||
@@ -0,0 +1,15 @@
|
||||
<c-layouts.add>
|
||||
<c-slot name="additional_row">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
<c-button type="submit"
|
||||
color="gray"
|
||||
name="submit_and_redirect"
|
||||
>
|
||||
Submit & Create Session
|
||||
</c-button>
|
||||
</td>
|
||||
</tr>
|
||||
</c-slot>
|
||||
</c-layouts.add>
|
||||
@@ -0,0 +1,38 @@
|
||||
<c-layouts.add>
|
||||
<c-slot name="form_content">
|
||||
<div class="max-width-container">
|
||||
<div id="add-form" class="form-container max-w-xl mx-auto">
|
||||
<form method="post" enctype="multipart/form-data" class="">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div>
|
||||
{{ field.label_tag }}
|
||||
{% if field.name == "note" %}
|
||||
{{ field }}
|
||||
{% else %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
||||
<span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
|
||||
<c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
|
||||
<c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
|
||||
<c-button data-target="{{ field.name }}" data-type="copy" size="xs">
|
||||
Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
|
||||
</c-button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div>
|
||||
<c-button type="submit">
|
||||
Submit
|
||||
</c-button>
|
||||
</div>
|
||||
<div class="submit-button-container">
|
||||
{{ additional_row }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</c-slot>
|
||||
</c-layouts.add>
|
||||
@@ -1,52 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
{% load static %}
|
||||
|
||||
<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"/>
|
||||
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||
</head>
|
||||
|
||||
<body class="dark">
|
||||
<div class="dark:bg-gray-800 min-h-screen">
|
||||
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
||||
<div class="container flex flex-wrap items-center justify-between mx-auto">
|
||||
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
|
||||
<span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
|
||||
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
|
||||
</a>
|
||||
<div class="w-full md:block md:w-auto">
|
||||
<ul
|
||||
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
|
||||
<li><a class="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 class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
|
||||
{% endif %}
|
||||
{% if edition_available %}
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
|
||||
{% endif %}
|
||||
{% if purchase_available %}
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
|
||||
{% endif %}
|
||||
{% if session_count > 0 %}
|
||||
<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>
|
||||
{% load version %}
|
||||
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<c-vars color="blue" size="base" type="button" />
|
||||
<button
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if type %}type="{{ type }}"{% endif %}
|
||||
{% if title %}title="{{ title }}"{% endif %}
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if data_target %}data-target="{{ data_target }}"{% endif %}
|
||||
{% if data_type %}data-type="{{ data_type }}"{% endif %}
|
||||
{% if name %}name="{{ name }}"{% endif %}
|
||||
class="{% if class %}{{ class }} {%else%}{%endif%}{% if color == "blue" %}text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} leading-5 focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-base {% if size == "xs" %} px-3 py-2 text-xs shadow-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
|
||||
{{ slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="inline-flex rounded-md shadow-xs" role="group">
|
||||
{% if slot %}{{ slot }}{% endif %}
|
||||
{% for button in buttons %}
|
||||
{% if button.slot %}
|
||||
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title :hx_get=button.hx_get :hx_target=button.hx_target :hx_swap=button.hx_swap />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<c-vars color="gray" />
|
||||
<a href="{{ href }}"
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if click %}@click="{{ click }}"{% endif %}
|
||||
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
|
||||
{% if color == "gray" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% elif color == "red" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% elif color == "green" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</a>
|
||||
@@ -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-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
|
||||
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
{% endcomment %}
|
||||
{{ text }}
|
||||
</a>
|
||||
@@ -0,0 +1,18 @@
|
||||
{% comment %}
|
||||
title
|
||||
text
|
||||
{% endcomment %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
autofocus
|
||||
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="self-center w-6 h-6 inline">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
{{ text }}
|
||||
</button>
|
||||
@@ -0,0 +1,10 @@
|
||||
<span class="truncate-container">
|
||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
||||
href="{% url 'games:view_game' game_id %}">
|
||||
{% if slot %}
|
||||
{{ slot }}
|
||||
{% else %}
|
||||
{{ name }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</span>
|
||||
@@ -0,0 +1,16 @@
|
||||
<span class="{% if display == 'flex' %}flex{% else %}inline-flex{% endif %} gap-2 items-center align-middle {{class}}">
|
||||
<span class="rounded-xl w-3 h-3
|
||||
{% if status == "u" %}
|
||||
bg-gray-500
|
||||
{% elif status == "p" %}
|
||||
bg-orange-400
|
||||
{% elif status == "f" %}
|
||||
bg-green-500
|
||||
{% elif status == "a" %}
|
||||
bg-red-500
|
||||
{% elif status == "r" %}
|
||||
bg-purple-500
|
||||
{% endif %}
|
||||
"> </span>
|
||||
{{ slot }}
|
||||
</span>
|
||||
@@ -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-sm dark:bg-blue-200 dark:text-blue-800 ms-2">
|
||||
{{ badge }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user