Compare commits
556 Commits
0.1.1
...
b6864e59ce
| Author | SHA1 | Date | |
|---|---|---|---|
| b6864e59ce | |||
| 36b1382015 | |||
| d101aecd70 | |||
| 09db54e940 | |||
| f090643026 | |||
| ec1828b823 | |||
| 94c3d9050a | |||
| ad47684dc1 | |||
| 66ec8e1eed | |||
| 1583c474b2 | |||
|
2f433c92da
|
|||
|
5b2b79f553
|
|||
|
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
|
|||
|
2640a49734
|
|||
|
65c175afb2
|
|||
|
0814071a26
|
|||
|
5f845f866e
|
|||
|
c3d4697470
|
|||
| 77293f03e9 | |||
| 1fa364e2ec | |||
| 4a6f4a2f9a | |||
| 9590988b6a | |||
| 938c82a395 | |||
| 33939f631c | |||
| ac8cd6534a | |||
| 51d8e953c0 | |||
| 2eec677f41 | |||
| f2eb14d3ef | |||
| c337d2200f | |||
| 8a8b05b0bd | |||
| 9446065271 | |||
| 755093845d | |||
| d4ab0596da | |||
| 8dcbe2f0ad | |||
| 25bc74eff1 | |||
| 8a7d083fb2 | |||
|
8296ebcf31
|
|||
| 04a4f2e0be | |||
| 4070b4e46e | |||
| 4892218c83 | |||
| 6b00a950ce | |||
| feee9d6dac | |||
|
9654fb017d
|
|||
| 1741397ee7 | |||
| da0c8d710b | |||
| 215374167b | |||
| 77268ae92f | |||
| c42687a072 | |||
| ca16345374 | |||
| 3a3045be91 | |||
|
d40612af72
|
|||
|
18e8f93261
|
|||
| 56e5dfaa03 | |||
| 2f00be455d | |||
| c3c9ae0632 | |||
|
55c2693f32
|
|||
|
972ff67050
|
|||
|
8ae99faa8e
|
|||
|
8e4086ce83
|
|||
|
2760068cde
|
|||
| cef797c333 | |||
| 4d91a76513 | |||
| e51d586255 | |||
| 2553d6f9e6 | |||
| 8cf6270d8f | |||
| 0b1089b0f4 | |||
| 9534492f17 | |||
| 8b7ed90b49 | |||
| 2ce4dd3a0e | |||
| a851b5329a | |||
| 6fa049e1b1 | |||
| 6b7ed0dbb5 | |||
| dd50d6dd40 | |||
| 162f4f3dbf | |||
| e8e6d5bcae | |||
| c5b451a258 | |||
| 163211ab0b | |||
| 64f5668dde | |||
| 465d958d9b | |||
| d8ece979a8 | |||
| 2defdd4657 | |||
| 078f87687f | |||
| 49723831e9 | |||
| 025ea0dd4e | |||
| 97467c7a52 | |||
| 7842d6f45d | |||
| b77089f7ad | |||
| 24f4459318 | |||
| 751182df52 | |||
| 33e136a810 | |||
| 362732c22a | |||
| 8e1c670ffd | |||
| e5a9b9aa50 | |||
|
c9b2d5bd8d
|
|||
|
0d20b543b0
|
|||
|
f7b69f7704
|
|||
|
1ccfdc321a
|
|||
|
25a58c2732
|
|||
|
270d9f7296
|
|||
|
2939b4a515
|
|||
| d029fda896 | |||
| 9dead362c1 | |||
| d81dba727b |
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.githooks
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.venv
|
||||
.vscode
|
||||
node_modules
|
||||
static
|
||||
.drone.yml
|
||||
.editorconfig
|
||||
.gitignore
|
||||
CHANGELOG.md
|
||||
db.sqlite3
|
||||
docker-compose*
|
||||
Dockerfile
|
||||
Makefile
|
||||
@@ -5,21 +5,52 @@ 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
|
||||
|
||||
- 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-non-prod
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.kucharczyk.xyz/timetracker
|
||||
tags:
|
||||
- ${DRONE_COMMIT_REF}
|
||||
- ${DRONE_COMMIT_BRANCH}
|
||||
when:
|
||||
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:
|
||||
event:
|
||||
- push
|
||||
- cron
|
||||
exclude:
|
||||
- pull_request
|
||||
@@ -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
|
||||
@@ -1,7 +1,14 @@
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.venv
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
db.sqlite3
|
||||
pnpm-lock.yaml
|
||||
db.sqlite3
|
||||
data/
|
||||
/static/
|
||||
dist/
|
||||
.DS_Store
|
||||
.python-version
|
||||
.direnv
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,288 @@
|
||||
## 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
|
||||
* Add icons for game, edition, purchase filters
|
||||
* Allow filtering by game, edition, purchase from the session list
|
||||
* Allow editing filtered entities from session list
|
||||
|
||||
## 1.0.2 / 2023-02-18 21:48+01:00
|
||||
|
||||
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
|
||||
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
|
||||
* Add support for purchase prices
|
||||
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
|
||||
|
||||
## 1.0.1 / 2023-01-30 22:17+01:00
|
||||
|
||||
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
|
||||
* Show markers on smaller graphs to make it clearer which dates the session belong to
|
||||
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
|
||||
|
||||
## 1.0.0 / 2023-01-20 19:54+01:00
|
||||
|
||||
* Breaking
|
||||
* Due to major re-arranging and re-naming of the folder structure, tables also had to be renamed.
|
||||
* Fixed
|
||||
* Sort form fields alphabetically (https://git.kucharczyk.xyz/lukas/timetracker/issues/39, https://git.kucharczyk.xyz/lukas/timetracker/issues/40)
|
||||
* Start session button starts different game than it says (#44)
|
||||
|
||||
## 0.2.5 / 2023-01-18 17:01+01:00
|
||||
|
||||
* New
|
||||
* When adding session, pre-select game with the last session
|
||||
* Fixed
|
||||
* Start session now button would take up 100% width, leading to accidental clicks (https://git.kucharczyk.xyz/lukas/timetracker/issues/37)
|
||||
* Removed
|
||||
* Session model property `last` is already implemented by Django method `last()`, thus it was removed (https://git.kucharczyk.xyz/lukas/timetracker/issues/38)
|
||||
|
||||
## 0.2.4 / 2023-01-16 19:39+01:00
|
||||
|
||||
* Fixed
|
||||
* When filtering by game, the "Filtering by (...)" text would erroneously list an unrelated platform
|
||||
* Playtime graph would display timeline backwards
|
||||
* Playtime graph with many dates would overlap (https://git.kucharczyk.xyz/lukas/timetracker/issues/34)
|
||||
* Manually added times (= without end timestamp) would make graphs look ugly and noisy (https://git.kucharczyk.xyz/lukas/timetracker/issues/35)
|
||||
|
||||
## 0.2.3 / 2023-01-15 23:13+01:00
|
||||
|
||||
* Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32)
|
||||
* Order session by newest as preparation for https://git.kucharczyk.xyz/lukas/timetracker/issues/33
|
||||
|
||||
## 0.2.2 / 2023-01-15 17:59+01:00
|
||||
|
||||
* Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29)
|
||||
* Fix error when showing session list with no sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/31)
|
||||
|
||||
## 0.2.1 / 2023-01-13 16:53+01:00
|
||||
|
||||
* List number of sessions when filtering on session list
|
||||
* Start sessions of last purchase from list (https://git.kucharczyk.xyz/lukas/timetracker/issues/19)
|
||||
|
||||
## 0.2.0 / 2023-01-09 22:42+01:00
|
||||
|
||||
* Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6)
|
||||
* Make formatting durations more robust, change default duration display to "X hours" (https://git.kucharczyk.xyz/lukas/timetracker/issues/26)
|
||||
|
||||
## 0.1.4 / 2023-01-08 15:45+01:00
|
||||
|
||||
* Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23)
|
||||
|
||||
## 0.1.3 / 2023-01-08 15:23+01:00
|
||||
|
||||
* Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22)
|
||||
|
||||
## 0.1.2 / 2023-01-07 22:05+01:00
|
||||
|
||||
* Switch to Uvicorn/Gunicorn + Caddy (https://git.kucharczyk.xyz/lukas/timetracker/pulls/4)
|
||||
|
||||
## 0.1.1 / 2023-01-05 23:26+01:00
|
||||
* Order by timestamp_start by default
|
||||
* Add pre-commit hook to update version
|
||||
@@ -19,4 +304,4 @@
|
||||
* Make it possible to add a new platform
|
||||
* Save calculated duration to database if both timestamps are set
|
||||
* Improve session listing
|
||||
* Set version in the footer to fixed, fix main container height
|
||||
* Set version in the footer to fixed, fix main container height
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages) |
|
||||
| Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) |
|
||||
| Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) |
|
||||
| Run tests | `make test` (or `uv run --with pytest-django pytest`) |
|
||||
| Make migrations | `make makemigrations` |
|
||||
| Apply migrations | `make migrate` |
|
||||
| CSS (Tailwind) | `make css` |
|
||||
| Django shell | `make shell` |
|
||||
| Create superuser | `make createsuperuser` |
|
||||
| Format Python | `make format` (or `uv run ruff format`) |
|
||||
| Lint Python | `make lint` (or `uv run ruff check`) |
|
||||
| Auto-fix lint | `make lint-fix` (`ruff check --fix`) |
|
||||
| Lint + format check + tests | `make check` (CI-style aggregate) |
|
||||
| Sync uv.lock | `uv sync` (after editing pyproject.toml) |
|
||||
|
||||
## Architecture
|
||||
|
||||
A Django 6+ monolith with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a custom server-side component system, plus a Django Ninja REST API.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
games/ — Django app: models, views, templates, forms, signals, tasks, API
|
||||
common/ — Shared utilities: time formatting, component system, HTML helpers
|
||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||
tests/ — Pytest tests
|
||||
contrib/ — One-off scripts (exchange rate import)
|
||||
```
|
||||
|
||||
### Models (in `games/models.py`)
|
||||
|
||||
- **Game** — name, platform, status (Unplayed/Played/Finished/Retired/Abandoned), mastered, playtime
|
||||
- **Platform** — name, group, icon slug
|
||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a GeneratedField), links to Game via M2M
|
||||
- **Session** — start/end timestamps, manual duration, device. `duration_calculated` and `duration_total` are GeneratedFields (cannot be written directly)
|
||||
- **Device** — name, type (PC/Console/Handheld/Mobile/SBC/Unknown)
|
||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a GeneratedField
|
||||
- **ExchangeRate** — cached FX rates per currency pair per year
|
||||
- **GameStatusChange** — audit log of status transitions
|
||||
|
||||
### Key patterns
|
||||
|
||||
**Component system** (`common/components.py`): Python functions return HTML via django-cotton templates. Every component wraps `Component()` which calls `render_to_string` (LRU-cached in production). Key helpers: `A()`, `Button()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `NameWithIcon()`, `LinkedPurchase()`, `Div()`, `Form()`.
|
||||
|
||||
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity: `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py`, `general.py`. The `general.py` has two context processors: `model_counts` and `global_current_year`.
|
||||
|
||||
**Signals** (`games/signals.py`):
|
||||
- `pre_save` on Purchase: snapshots old price/currency for change detection
|
||||
- `post_save` on Purchase: sets `needs_price_update` if price/currency changed
|
||||
- `m2m_changed` on Purchase.games: updates `num_purchases` count
|
||||
- `pre_delete` on Game: decrements `num_purchases` on related Purchases
|
||||
- `post_save/post_delete` on Session: recalculates Game.playtime
|
||||
- `pre_save` on Game: creates GameStatusChange audit records
|
||||
|
||||
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates and convert purchase prices to CZK.
|
||||
|
||||
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present.
|
||||
|
||||
**REST API** (`games/api.py`): Django Ninja with routers for playevents, games, and sessions. Game status and session device can be PATCHed via the API.
|
||||
|
||||
### Templates
|
||||
|
||||
Templates live in `games/templates/`. The layout uses django-cotton components in `templates/cotton/` — a reusable component library with `button.html`, `table.html`, `popover.html`, etc. Platform icons are stored as individual HTML snippet files under `cotton/icon/<slug>.html`. Partials for HTMX responses are in `templates/partials/`.
|
||||
|
||||
### Deployment
|
||||
|
||||
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via Drone (`.drone.yml`): runs tests, builds Docker image, deploys via Portainer webhook.
|
||||
|
||||
### Database
|
||||
|
||||
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are GeneratedFields on the models — these are computed by the database engine and cannot be written from application code.
|
||||
|
||||
### Configuration
|
||||
|
||||
- `DEBUG` is `True` unless `PROD` env var is set
|
||||
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var
|
||||
- Django Admin and Debug Toolbar are only available in DEBUG mode
|
||||
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
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
|
||||
}
|
||||
@@ -1,26 +1,55 @@
|
||||
FROM node as css
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN npm install && \
|
||||
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
|
||||
|
||||
FROM python:3.10-slim-bullseye
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
ENV VERSION_NUMBER 0.1.0-48-gdb5de81
|
||||
ENV PROD 1
|
||||
|
||||
RUN useradd --create-home --uid 1000 timetracker
|
||||
WORKDIR /home/timetracker/app
|
||||
COPY . /home/timetracker/app/
|
||||
RUN chown -R timetracker:timetracker /home/timetracker/app
|
||||
COPY --from=css /app/src/web/tracker/static/base.css /home/timetracker/app/src/web/tracker/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
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,52 +1,90 @@
|
||||
.PHONY: createsuperuser shell
|
||||
|
||||
all: css migrate
|
||||
|
||||
initialize: npm css migrate sethookdir loadplatforms
|
||||
initialize: npm css migrate loadplatforms
|
||||
|
||||
HTMLFILES := $(shell find src/web/tracker/templates -type f)
|
||||
PYTHON_VERSION = 3.12
|
||||
|
||||
npm:
|
||||
npm install
|
||||
pnpm install
|
||||
|
||||
css: src/input.css
|
||||
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css
|
||||
|
||||
css-dev: css
|
||||
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --watch
|
||||
css: common/input.css
|
||||
pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
makemigrations:
|
||||
poetry run python src/web/manage.py makemigrations
|
||||
uv run python manage.py makemigrations
|
||||
|
||||
migrate: makemigrations
|
||||
poetry run python src/web/manage.py migrate
|
||||
uv run python manage.py migrate
|
||||
|
||||
dev: migrate sethookdir
|
||||
poetry run python src/web/manage.py runserver_plus
|
||||
init:
|
||||
uv python install $(PYTHON_VERSION)
|
||||
uv sync
|
||||
pnpm install
|
||||
$(MAKE) loadplatforms
|
||||
|
||||
dumptracker:
|
||||
poetry run python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml
|
||||
dev:
|
||||
@pnpm concurrently \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
caddy run --watch
|
||||
|
||||
dev-prod: migrate collectstatic
|
||||
@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:
|
||||
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||
|
||||
loadplatforms:
|
||||
poetry run python src/web/manage.py loaddata platforms.yaml
|
||||
uv run python manage.py loaddata platforms.yaml
|
||||
|
||||
loadall:
|
||||
uv run python manage.py loaddata data.yaml
|
||||
|
||||
loadsample:
|
||||
poetry run python src/web/manage.py loaddata sample.yaml
|
||||
uv run python manage.py loaddata sample.yaml
|
||||
|
||||
createsuperuser:
|
||||
poetry run python src/web/manage.py createsuperuser
|
||||
uv run python manage.py createsuperuser
|
||||
|
||||
shell:
|
||||
poetry run python src/web/manage.py shell
|
||||
uv run python manage.py shell
|
||||
|
||||
poetry.lock: pyproject.toml
|
||||
poetry install
|
||||
collectstatic:
|
||||
uv run python manage.py collectstatic --clear --no-input
|
||||
|
||||
test: poetry.lock
|
||||
poetry run pytest
|
||||
uv.lock: pyproject.toml
|
||||
uv sync
|
||||
|
||||
sethookdir:
|
||||
git config core.hooksPath .githooks
|
||||
test: uv.lock
|
||||
uv run --with pytest-django pytest
|
||||
|
||||
make date:
|
||||
python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
lint:
|
||||
uv run ruff check
|
||||
|
||||
lint-fix:
|
||||
uv run ruff check --fix
|
||||
|
||||
format:
|
||||
uv run ruff format
|
||||
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check test
|
||||
|
||||
date:
|
||||
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/*
|
||||
|
||||
clean: cleanstatic
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# Timetracker
|
||||
|
||||
A simple game catalogue and play session tracker.
|
||||
A simple game catalogue and play session tracker.
|
||||
|
||||
# Development
|
||||
|
||||
The project uses `uv` to manage Python versions and dependencies.
|
||||
Simply run:
|
||||
|
||||
```
|
||||
make init
|
||||
```
|
||||
|
||||
This installs the correct Python version, syncs all dependencies, and installs npm packages.
|
||||
Afterwards, you can start the development server using `make dev`.
|
||||
@@ -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,48 @@
|
||||
with open("common/components.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Count FilterBar functions to know which replacement targets which
|
||||
n = content.count('("name", "filter"), ("value", escape(filter_json))')
|
||||
print(f"Found {n} hidden filter inputs")
|
||||
|
||||
# Simple: after each hidden filter input, insert a search input
|
||||
search_html = ''' Component(tag_name="input", attributes=[
|
||||
("type", "text"), ("name", "filter-search"),
|
||||
("value", escape(search_val)),
|
||||
("placeholder", "Search\u2026"),
|
||||
("class", "block w-full rounded-base border border-default-medium "
|
||||
"bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 "
|
||||
"focus:ring-brand focus:border-brand"),
|
||||
]),
|
||||
'''
|
||||
|
||||
old = ''' Component(tag_name="input", attributes=[
|
||||
("type", "hidden"), ("id", filter_input_id),
|
||||
("name", "filter"), ("value", escape(filter_json)),
|
||||
]),
|
||||
Component(tag_name="div", attributes=['''
|
||||
|
||||
# Only replace occurrences in FilterBar functions (after 'def FilterBar' or 'def SessionFilterBar' or 'def PurchaseFilterBar')
|
||||
# Find each occurrence and replace
|
||||
import re
|
||||
# Strategy: split by the old pattern, insert search_html between first two parts of each split
|
||||
parts = content.split(old)
|
||||
print(f"Split into {len(parts)} parts")
|
||||
|
||||
new_content = parts[0]
|
||||
for i in range(1, len(parts)):
|
||||
# Check if this occurrence is inside a FilterBar function (not inside SelectableFilter)
|
||||
# Simple heuristic: the context before should contain 'FilterBar'
|
||||
chunk_before = parts[i-1][-500:] if len(parts[i-1]) > 500 else parts[i-1]
|
||||
is_filterbar = 'FilterBar' in chunk_before or 'filter_bar' in chunk_before.lower()
|
||||
if is_filterbar:
|
||||
new_content += old + search_html + parts[i]
|
||||
else:
|
||||
new_content += old + parts[i]
|
||||
|
||||
with open("common/components.py", "w") as f:
|
||||
f.write(new_content)
|
||||
|
||||
import ast
|
||||
ast.parse(new_content)
|
||||
print("OK")
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Typed criterion inputs for building structured filters.
|
||||
|
||||
Inspired by Stash's filter architecture: every filterable field uses a typed
|
||||
criterion with a value and a CriterionModifier. This separates *what* you're
|
||||
filtering from *how* you're comparing, and makes filter serialization trivial.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field, fields as dc_fields
|
||||
from enum import Enum
|
||||
from typing import Any, Self, TypeVar
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
# ── Modifier ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Modifier(str, Enum):
|
||||
"""Comparison operators shared across all criterion types."""
|
||||
|
||||
EQUALS = "EQUALS"
|
||||
NOT_EQUALS = "NOT_EQUALS"
|
||||
GREATER_THAN = "GREATER_THAN"
|
||||
LESS_THAN = "LESS_THAN"
|
||||
BETWEEN = "BETWEEN"
|
||||
NOT_BETWEEN = "NOT_BETWEEN"
|
||||
INCLUDES = "INCLUDES"
|
||||
EXCLUDES = "EXCLUDES"
|
||||
INCLUDES_ALL = "INCLUDES_ALL"
|
||||
IS_NULL = "IS_NULL"
|
||||
NOT_NULL = "NOT_NULL"
|
||||
MATCHES_REGEX = "MATCHES_REGEX"
|
||||
NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
|
||||
|
||||
@classmethod
|
||||
def for_strings(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS, cls.NOT_EQUALS,
|
||||
cls.INCLUDES, cls.EXCLUDES,
|
||||
cls.MATCHES_REGEX, cls.NOT_MATCHES_REGEX,
|
||||
cls.IS_NULL, cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_numbers(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS, cls.NOT_EQUALS,
|
||||
cls.GREATER_THAN, cls.LESS_THAN,
|
||||
cls.BETWEEN, cls.NOT_BETWEEN,
|
||||
cls.IS_NULL, cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_dates(cls) -> list[Self]:
|
||||
return cls.for_numbers()
|
||||
|
||||
@classmethod
|
||||
def for_multi(cls) -> list[Self]:
|
||||
return [
|
||||
cls.INCLUDES, cls.EXCLUDES,
|
||||
cls.INCLUDES_ALL,
|
||||
cls.IS_NULL, cls.NOT_NULL,
|
||||
]
|
||||
|
||||
|
||||
# ── Base criterion ─────────────────────────────────────────────────────────
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Criterion:
|
||||
"""Base for all typed criteria."""
|
||||
|
||||
value: Any = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict | None) -> Self | None:
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
kwargs: dict[str, Any] = {}
|
||||
for f in dc_fields(cls):
|
||||
if f.name in data:
|
||||
val = data[f.name]
|
||||
# Coerce string modifier to Modifier enum
|
||||
if f.name == "modifier" and isinstance(val, str):
|
||||
val = Modifier(val)
|
||||
kwargs[f.name] = val
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for f in dc_fields(self):
|
||||
v = getattr(self, f.name)
|
||||
if v is not None and v != f.default:
|
||||
result[f.name] = v
|
||||
return result
|
||||
|
||||
|
||||
# ── Concrete criteria ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class StringCriterion(_Criterion):
|
||||
value: str = ""
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.INCLUDES:
|
||||
return Q(**{f"{field_name}__icontains": self.value})
|
||||
if m == Modifier.EXCLUDES:
|
||||
return ~Q(**{f"{field_name}__icontains": self.value})
|
||||
if m == Modifier.MATCHES_REGEX:
|
||||
return Q(**{f"{field_name}__regex": self.value})
|
||||
if m == Modifier.NOT_MATCHES_REGEX:
|
||||
return ~Q(**{f"{field_name}__regex": self.value})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for string field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntCriterion(_Criterion):
|
||||
value: int = 0
|
||||
value2: int | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2)})
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
|
||||
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for int field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FloatCriterion(_Criterion):
|
||||
value: float = 0.0
|
||||
value2: float | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2)})
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
|
||||
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for float field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateCriterion(_Criterion):
|
||||
value: str = ""
|
||||
value2: str | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__gte": self.value,
|
||||
f"{field_name}__lte": self.value2})
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__lt": self.value}) | Q(**{f"{field_name}__gt": self.value2})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for date field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoolCriterion(_Criterion):
|
||||
value: bool = False
|
||||
# Bool only makes sense with EQUALS
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
if self.modifier == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if self.modifier == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiCriterion(_Criterion):
|
||||
"""Filter on a many-to-many or ForeignKey relationship by ID list."""
|
||||
value: list[int] = field(default_factory=list)
|
||||
excludes: list[int] = field(default_factory=list)
|
||||
modifier: Modifier = Modifier.INCLUDES
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.INCLUDES:
|
||||
q = Q(**{f"{field_name}__in": self.value})
|
||||
if self.excludes:
|
||||
q &= ~Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
if m == Modifier.EXCLUDES:
|
||||
return ~Q(**{f"{field_name}__in": self.value})
|
||||
if m == Modifier.INCLUDES_ALL:
|
||||
q = Q()
|
||||
for v in self.value:
|
||||
q &= Q(**{field_name: v})
|
||||
return q
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for multi field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceCriterion(_Criterion):
|
||||
"""Filter on a choice/enum field with multi-select include/exclude.
|
||||
|
||||
Used by SelectableFilter widgets for status, ownership_type, etc.
|
||||
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
|
||||
"""
|
||||
|
||||
value: list[str] = field(default_factory=list)
|
||||
excludes: list[str] = field(default_factory=list)
|
||||
modifier: Modifier = Modifier.INCLUDES
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.INCLUDES:
|
||||
q = Q()
|
||||
if self.value:
|
||||
q &= Q(**{f"{field_name}__in": self.value})
|
||||
if self.excludes:
|
||||
q &= ~Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
if m == Modifier.EXCLUDES:
|
||||
q = Q()
|
||||
if self.value:
|
||||
q &= ~Q(**{f"{field_name}__in": self.value})
|
||||
if self.excludes:
|
||||
q &= Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
if m == Modifier.EQUALS:
|
||||
q = Q()
|
||||
if self.value:
|
||||
q &= Q(**{f"{field_name}__in": self.value})
|
||||
if self.excludes:
|
||||
q &= ~Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{f"{field_name}__in": self.value})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for choice field")
|
||||
|
||||
|
||||
# ── OperatorFilter base ────────────────────────────────────────────────────
|
||||
|
||||
F = TypeVar("F", bound="OperatorFilter")
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatorFilter:
|
||||
"""Mixin providing AND/OR/NOT composition for entity filter types.
|
||||
|
||||
Subclasses should declare nullable references to themselves::
|
||||
|
||||
@dataclass
|
||||
class GameFilter(OperatorFilter):
|
||||
AND: "GameFilter | None" = None
|
||||
OR: "GameFilter | None" = None
|
||||
NOT: "GameFilter | None" = None
|
||||
name: StringCriterion | None = None
|
||||
...
|
||||
"""
|
||||
|
||||
def sub_filter(self) -> OperatorFilter | None:
|
||||
"""Return the first non-None of AND / OR / NOT."""
|
||||
for attr in ("AND", "OR", "NOT"):
|
||||
if hasattr(self, attr):
|
||||
v = getattr(self, attr)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
def _criterion_fields(self) -> list[str]:
|
||||
"""Return field names that hold a _Criterion instance."""
|
||||
names: list[str] = []
|
||||
for f in dc_fields(self):
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
continue
|
||||
v = getattr(self, f.name)
|
||||
if isinstance(v, _Criterion):
|
||||
names.append(f.name)
|
||||
return names
|
||||
|
||||
def to_q(self) -> Q:
|
||||
"""Build a Django Q object from this filter and its sub-filters."""
|
||||
q = Q()
|
||||
for field_name in self._criterion_fields():
|
||||
c = getattr(self, field_name)
|
||||
if c is not None:
|
||||
q &= c.to_q(field_name)
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if getattr(self, "AND", None) is not None:
|
||||
q &= sub.to_q()
|
||||
elif getattr(self, "OR", None) is not None:
|
||||
q |= sub.to_q()
|
||||
elif getattr(self, "NOT", None) is not None:
|
||||
q &= ~sub.to_q()
|
||||
return q
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any] | None) -> Self | None:
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
# Resolve criterion class names to actual types
|
||||
criterion_types: dict[str, type[_Criterion]] = {
|
||||
"StringCriterion": StringCriterion,
|
||||
"IntCriterion": IntCriterion,
|
||||
"FloatCriterion": FloatCriterion,
|
||||
"DateCriterion": DateCriterion,
|
||||
"BoolCriterion": BoolCriterion,
|
||||
"MultiCriterion": MultiCriterion,
|
||||
"ChoiceCriterion": ChoiceCriterion,
|
||||
}
|
||||
kwargs: dict[str, Any] = {}
|
||||
for f in dc_fields(cls):
|
||||
if f.name not in data:
|
||||
continue
|
||||
raw = data[f.name]
|
||||
if raw is None:
|
||||
kwargs[f.name] = None
|
||||
continue
|
||||
# Recurse into sub-filters (AND / OR / NOT)
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
continue
|
||||
# Resolve criterion fields from string type annotation
|
||||
f_type = f.type
|
||||
if isinstance(f_type, str):
|
||||
# e.g. "StringCriterion | None" → "StringCriterion"
|
||||
f_type = f_type.split("|")[0].strip()
|
||||
if isinstance(f_type, str) and f_type in criterion_types:
|
||||
criterion_cls = criterion_types[f_type]
|
||||
kwargs[f.name] = criterion_cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
|
||||
kwargs[f.name] = f_type.from_json(raw) if isinstance(raw, dict) else None
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for f in dc_fields(self):
|
||||
v = getattr(self, f.name)
|
||||
if v is None:
|
||||
continue
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
result[f.name] = v.to_json()
|
||||
elif isinstance(v, _Criterion):
|
||||
j = v.to_json()
|
||||
if j:
|
||||
result[f.name] = j
|
||||
return result
|
||||
|
||||
|
||||
# ── JSON helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def filter_from_json(cls: type[F], json_str: str) -> F | None:
|
||||
"""Deserialize a filter from a JSON string.
|
||||
|
||||
Usage:
|
||||
f = filter_from_json(GameFilter, request.GET.get("filter", ""))
|
||||
games = Game.objects.filter(f.to_q())
|
||||
"""
|
||||
if not json_str:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return cls.from_json(data)
|
||||
|
||||
|
||||
def filter_to_json(f: OperatorFilter) -> str:
|
||||
"""Serialize a filter to a JSON string for URL params or storage."""
|
||||
return json.dumps(f.to_json())
|
||||
@@ -0,0 +1,27 @@
|
||||
import functools
|
||||
from pathlib import Path
|
||||
|
||||
_ICON_DIR = (
|
||||
Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _load_icons() -> dict[str, str]:
|
||||
"""Load all icon HTML files into a dict.
|
||||
|
||||
Cached so files are read once per process lifetime.
|
||||
Delegation (e.g. nintendo-3ds -> nintendo) is handled by
|
||||
both files containing identical SVG content.
|
||||
"""
|
||||
icons: dict[str, str] = {}
|
||||
for filepath in _ICON_DIR.glob("*.html"):
|
||||
name = filepath.stem
|
||||
icons[name] = filepath.read_text()
|
||||
return icons
|
||||
|
||||
|
||||
def get_icon(name: str) -> str:
|
||||
"""Return the HTML for an icon by name. Falls back to 'unspecified'."""
|
||||
icons = _load_icons()
|
||||
return icons.get(name, icons.get("unspecified", ""))
|
||||
@@ -0,0 +1,30 @@
|
||||
import csv
|
||||
from typing import TypeAlias
|
||||
|
||||
from games.models import Game
|
||||
|
||||
DataList: TypeAlias = list[dict[str, str]] | None
|
||||
|
||||
|
||||
def read_csv(filename: str) -> DataList:
|
||||
with open(filename, "r") as csvfile:
|
||||
writer = csv.DictReader(csvfile)
|
||||
return writer
|
||||
|
||||
|
||||
def import_data(data: DataList):
|
||||
matching_names = {}
|
||||
for line in data:
|
||||
name = line["name"]
|
||||
if name not in matching_names:
|
||||
# try exact match first
|
||||
try:
|
||||
game_id = Game.objects.get(name__iexact=name)
|
||||
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
|
||||
game_id = None
|
||||
matching_names[name] = game_id
|
||||
print(f"Exact matched {len(matching_names)} games.")
|
||||
|
||||
|
||||
def import_from_file(filename: str):
|
||||
import_data(read_csv(filename))
|
||||
@@ -0,0 +1,233 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@utility min-w-20char {
|
||||
min-width: 20ch;
|
||||
}
|
||||
|
||||
@utility max-w-20char {
|
||||
max-width: 20ch;
|
||||
}
|
||||
|
||||
@utility min-w-30char {
|
||||
min-width: 30ch;
|
||||
}
|
||||
|
||||
@utility max-w-30char {
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
@utility max-w-35char {
|
||||
max-width: 35ch;
|
||||
}
|
||||
|
||||
@utility max-w-40char {
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@font-face {
|
||||
font-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;
|
||||
}
|
||||
|
||||
.basic-button-container {
|
||||
@apply flex space-x-2 justify-center;
|
||||
}
|
||||
|
||||
.basic-button {
|
||||
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded-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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"""A small fast_app-style layout system.
|
||||
|
||||
Instead of Django template inheritance (`{% extends "base.html" %}`), views
|
||||
build their page body with Python components and wrap it with `Page()` /
|
||||
`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
|
||||
it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
||||
`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.contrib.messages import get_messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
from django_htmx.jinja import django_htmx_script
|
||||
|
||||
from games.templatetags.version import version, version_date
|
||||
|
||||
# Static head script that sets the dark/light class before paint (avoids FOUC).
|
||||
_THEME_FOUC_SCRIPT = """<script>
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
</script>"""
|
||||
|
||||
# The main module script: crown icon mount + theme-toggle wiring.
|
||||
# Split around the single dynamic value (game.mastered).
|
||||
_MAIN_SCRIPT_A = """<script type="module">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.mountCrownIcon) {
|
||||
window.mountCrownIcon('#crown-icon-mount-point', {
|
||||
mastered: """
|
||||
_MAIN_SCRIPT_B = """
|
||||
});
|
||||
}
|
||||
|
||||
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
|
||||
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
themeToggleDarkIcon.classList.add('hidden');
|
||||
} else {
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
themeToggleLightIcon.classList.add('hidden');
|
||||
}
|
||||
|
||||
themeToggleBtn.addEventListener('click', function () {
|
||||
themeToggleDarkIcon.classList.toggle('hidden');
|
||||
themeToggleLightIcon.classList.toggle('hidden');
|
||||
|
||||
if (localStorage.getItem('color-theme')) {
|
||||
if (localStorage.getItem('color-theme') === 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
}
|
||||
} else {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>"""
|
||||
|
||||
# Toast notification region (Alpine.js). Verbatim from the old base.html.
|
||||
_TOAST_CONTAINER = """<div x-data="toastStore()"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-atomic="true"
|
||||
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
|
||||
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
|
||||
<div x-show="toast.visible"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-8"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-8"
|
||||
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
|
||||
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
|
||||
tabindex="0"
|
||||
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
|
||||
:class="{
|
||||
'success': toast.type === 'success',
|
||||
'error': toast.type === 'error',
|
||||
'info': toast.type === 'info',
|
||||
'warning': toast.type === 'warning',
|
||||
'debug': toast.type === 'debug'
|
||||
}"
|
||||
@click="dismissToast(toast.id)"
|
||||
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
|
||||
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
|
||||
@keydown.escape="dismissToast(toast.id)">
|
||||
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
|
||||
:class="{
|
||||
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
|
||||
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
|
||||
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
|
||||
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
|
||||
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
|
||||
}">
|
||||
<span class="flex-shrink-0 mt-0.5"
|
||||
:class="{
|
||||
'text-green-500': toast.type === 'success',
|
||||
'text-red-500': toast.type === 'error',
|
||||
'text-blue-500': toast.type === 'info',
|
||||
'text-amber-500': toast.type === 'warning',
|
||||
'text-gray-500': toast.type === 'debug'
|
||||
}">
|
||||
<template x-if="toast.type === 'success'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'error'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'info'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'warning'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'debug'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</span>
|
||||
<p class="flex-1 text-sm"
|
||||
:class="{
|
||||
'text-green-800 dark:text-green-200': toast.type === 'success',
|
||||
'text-red-800 dark:text-red-200': toast.type === 'error',
|
||||
'text-blue-800 dark:text-blue-200': toast.type === 'info',
|
||||
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
|
||||
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
|
||||
}"
|
||||
x-text="toast.message"></p>
|
||||
<button @click.stop="dismissToast(toast.id)"
|
||||
class="flex-shrink-0"
|
||||
:class="{
|
||||
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
|
||||
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
|
||||
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
|
||||
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
|
||||
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
|
||||
}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _main_script(mastered: bool) -> str:
|
||||
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
|
||||
|
||||
|
||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
|
||||
"""Top navigation bar."""
|
||||
logo = static("icons/schedule.png")
|
||||
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<a href="{reverse('games:index')}"
|
||||
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
|
||||
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
|
||||
</a>
|
||||
<button data-collapse-toggle="navbar-dropdown" type="button"
|
||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
aria-controls="navbar-dropdown" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
||||
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||
<li class="flex items-center">
|
||||
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
<li class="dark:text-white flex flex-col items-center text-xs">
|
||||
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
|
||||
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<button id="dropdownNavbarNewLink" data-dropdown-toggle="dropdownNavbarNew"
|
||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||
New
|
||||
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse('games:add_device')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
|
||||
<li><a href="{reverse('games:add_game')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
|
||||
<li><a href="{reverse('games:add_platform')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
|
||||
<li><a href="{reverse('games:add_purchase')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
|
||||
<li><a href="{reverse('games:add_session')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<button id="dropdownNavbarManageLink" data-dropdown-toggle="dropdownNavbarManage"
|
||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||
Manage
|
||||
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse('games:list_devices')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
|
||||
<li><a href="{reverse('games:list_games')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
|
||||
<li><a href="{reverse('games:list_platforms')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
|
||||
<li><a href="{reverse('games:list_playevents')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
|
||||
<li><a href="{reverse('games:list_purchases')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
|
||||
<li><a href="{reverse('games:list_sessions')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse('games:stats_by_year', args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse('logout')}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>""")
|
||||
|
||||
|
||||
def Page(
|
||||
content: SafeText | str,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
mastered: bool = False,
|
||||
) -> SafeText:
|
||||
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
||||
from games.views.general import global_current_year, model_counts
|
||||
|
||||
counts = model_counts(request)
|
||||
year = global_current_year(request)["global_current_year"]
|
||||
navbar = Navbar(
|
||||
today_played=counts["today_played"],
|
||||
last_7_played=counts["last_7_played"],
|
||||
current_year=year,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"message": str(m.message), "type": (m.tags or "info")}
|
||||
for m in get_messages(request)
|
||||
]
|
||||
# Embed as JSON; guard against `</script>` breaking out of the tag.
|
||||
messages_json = json.dumps(messages).replace("</", "<\\/")
|
||||
|
||||
head = (
|
||||
'<!DOCTYPE html>\n<html lang="en">\n <head>\n'
|
||||
' <meta charset="utf-8" />\n'
|
||||
' <meta name="description" content="Self-hosted time-tracker." />\n'
|
||||
' <meta name="keywords" content="time, tracking, video games, self-hosted" />\n'
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />\n'
|
||||
f" <title>Timetracker - {conditional_escape(title)}</title>\n"
|
||||
f' <script src="{static("js/htmx.min.js")}"></script>\n'
|
||||
" <script>\n"
|
||||
" htmx.config.scrollBehavior = 'smooth';\n"
|
||||
" htmx.config.selfRequestsOnly = false;\n"
|
||||
" </script>\n"
|
||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||
f" {django_htmx_script(nonce=None)}\n"
|
||||
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
f" {_THEME_FOUC_SCRIPT}\n"
|
||||
" </head>\n"
|
||||
)
|
||||
|
||||
body = (
|
||||
' <body hx-indicator="#indicator" class="bg-neutral-primary">\n'
|
||||
f' <script id="django-messages" type="application/json">{messages_json}</script>\n'
|
||||
f' <img id="indicator" src="{static("icons/loading.png")}" class="absolute right-3 top-3 animate-spin htmx-indicator" height="24" width="24" alt="loading indicator" />\n'
|
||||
' <div class="flex flex-col min-h-screen">\n'
|
||||
f" {navbar}\n"
|
||||
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
||||
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||
" </div>\n"
|
||||
f" {scripts}\n"
|
||||
f" {_main_script(mastered)}\n"
|
||||
' <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n'
|
||||
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
||||
f" {_TOAST_CONTAINER}\n"
|
||||
f' <script src="{static("js/toast.js")}"></script>\n'
|
||||
" </body>\n</html>\n"
|
||||
)
|
||||
|
||||
return mark_safe(head + body)
|
||||
|
||||
|
||||
def render_page(
|
||||
request: HttpRequest,
|
||||
content: SafeText | str,
|
||||
*,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
mastered: bool = False,
|
||||
status: int = 200,
|
||||
) -> HttpResponse:
|
||||
"""`render()`-style shortcut: build a full page and return an HttpResponse."""
|
||||
return HttpResponse(
|
||||
Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
|
||||
status=status,
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from common.utils import generate_split_ranges
|
||||
|
||||
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 is None:
|
||||
return timedelta(0)
|
||||
elif isinstance(duration, int):
|
||||
return timedelta(seconds=duration)
|
||||
elif isinstance(duration, timedelta):
|
||||
return duration
|
||||
|
||||
|
||||
def format_duration(
|
||||
duration: timedelta | int | float | None, format_string: str = "%H hours"
|
||||
) -> str:
|
||||
"""
|
||||
Format timedelta into the specified format_string.
|
||||
Valid format variables:
|
||||
- %H hours
|
||||
- %m minutes
|
||||
- %s seconds
|
||||
- %r total seconds
|
||||
Values don't change into higher units if those units are missing
|
||||
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
|
||||
safe_duration = _safe_timedelta(duration)
|
||||
# we don't need float
|
||||
seconds_total = int(safe_duration.total_seconds())
|
||||
# timestamps where end is before start
|
||||
if seconds_total < 0:
|
||||
seconds_total = 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 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) 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():
|
||||
# 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)
|
||||
@@ -0,0 +1,190 @@
|
||||
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.core.paginator import Page, Paginator
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
def paginate(request: HttpRequest, queryset, per_page: int = 10):
|
||||
"""Standard list-view pagination.
|
||||
|
||||
Reads ``page`` and ``limit`` from the query string (``limit=0`` disables
|
||||
pagination) and returns ``(object_list, page_obj, elided_page_range)`` ready
|
||||
to hand to ``paginated_table_content``.
|
||||
"""
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = int(request.GET.get("limit", per_page))
|
||||
object_list = queryset
|
||||
page_obj: Page | None = None
|
||||
if limit != 0:
|
||||
page_obj = Paginator(queryset, limit).get_page(page_number)
|
||||
object_list = page_obj.object_list
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
return object_list, page_obj, elided_page_range
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Execute the original view logic for its side effects, then
|
||||
# redirect to `next_url` instead of returning its response.
|
||||
view_func(request, *args, **kwargs)
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
services:
|
||||
timetracker:
|
||||
image: registry.kucharczyk.xyz/timetracker
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- TZ=Europe/Prague
|
||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||
user: "1000"
|
||||
# volumes:
|
||||
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
services:
|
||||
timetracker:
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- 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:
|
||||
- "./data:/home/timetracker/app/data"
|
||||
- "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Apply database migrations
|
||||
echo "Apply database migrations"
|
||||
poetry run python src/web/manage.py migrate
|
||||
set -euo pipefail
|
||||
|
||||
# Start server
|
||||
echo "Starting server"
|
||||
poetry run python src/web/manage.py runserver 0.0.0.0:8000
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-100}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
from django.contrib import admin
|
||||
from .models import Game, Purchase, Platform, 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +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,384 @@
|
||||
"""
|
||||
Entity-specific filter types for the timetracker app.
|
||||
|
||||
Each filter class mirrors a Django model, with fields expressed as typed
|
||||
criteria from common.criteria. The to_q() method produces a Django Q object
|
||||
ready for queryset.filter().
|
||||
|
||||
Inspired by Stash's filter architecture: each entity has an OperatorFilter
|
||||
with AND/OR/NOT composition and typed criterion fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
FloatCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
MultiCriterion,
|
||||
OperatorFilter,
|
||||
StringCriterion,
|
||||
filter_from_json,
|
||||
)
|
||||
|
||||
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FindFilter:
|
||||
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
|
||||
|
||||
q: str | None = None # free-text search
|
||||
page: int = 1
|
||||
per_page: int = 25
|
||||
sort: str | None = None # e.g. "-created_at"
|
||||
direction: str = "desc" # asc / desc
|
||||
|
||||
|
||||
# ── GameFilter ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameFilter(OperatorFilter):
|
||||
"""Filter for the Game model."""
|
||||
|
||||
AND: GameFilter | None = None
|
||||
OR: GameFilter | None = None
|
||||
NOT: GameFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
sort_name: StringCriterion | None = None
|
||||
year_released: IntCriterion | None = None
|
||||
original_year_released: IntCriterion | None = None
|
||||
wikidata: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # selectable filter widget
|
||||
status: ChoiceCriterion | None = None # selectable filter widget
|
||||
mastered: BoolCriterion | None = None
|
||||
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
|
||||
created_at: StringCriterion | None = None # date string
|
||||
updated_at: StringCriterion | None = None # date string
|
||||
|
||||
# Free-text search (combines name + sort_name + platform name)
|
||||
search: StringCriterion | None = None
|
||||
|
||||
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
|
||||
from django.db.models import Q
|
||||
|
||||
q = Q()
|
||||
|
||||
# ── individual criteria ──
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.sort_name is not None:
|
||||
q &= self.sort_name.to_q("sort_name")
|
||||
if self.year_released is not None:
|
||||
q &= self.year_released.to_q("year_released")
|
||||
if self.original_year_released is not None:
|
||||
q &= self.original_year_released.to_q("original_year_released")
|
||||
if self.wikidata is not None:
|
||||
q &= self.wikidata.to_q("wikidata")
|
||||
if self.platform is not None:
|
||||
q &= self.platform.to_q("platform_id")
|
||||
if self.status is not None:
|
||||
q &= self.status.to_q("status")
|
||||
if self.mastered is not None:
|
||||
q &= self.mastered.to_q("mastered")
|
||||
if self.playtime_minutes is not None:
|
||||
q &= self._playtime_to_q(self.playtime_minutes)
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
|
||||
# ── free-text search (OR across multiple fields) ──
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(sort_name__icontains=self.search.value)
|
||||
| Q(platform__name__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# ── AND / OR / NOT sub-filters ──
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
|
||||
"""Convert minutes-based criterion to a DurationField Q object.
|
||||
|
||||
Django stores DurationField as microseconds in SQLite, so we convert
|
||||
minutes → timedelta(microseconds=X) and use the appropriate lookups.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from common.criteria import Modifier
|
||||
from django.db.models import Q
|
||||
|
||||
m = c.modifier
|
||||
field = "playtime"
|
||||
td_val = timedelta(minutes=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field}__gt": td_val})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field}__lt": td_val})
|
||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field}": timedelta(0)})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
|
||||
|
||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionFilter(OperatorFilter):
|
||||
"""Filter for the Session model."""
|
||||
|
||||
AND: SessionFilter | None = None
|
||||
OR: SessionFilter | None = None
|
||||
NOT: SessionFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
device: MultiCriterion | None = None # filters on device_id
|
||||
emulated: BoolCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
duration_minutes: IntCriterion | None = None # on duration_total
|
||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||
timestamp_start: StringCriterion | None = None # date string
|
||||
timestamp_end: StringCriterion | None = None # date string
|
||||
is_manual: BoolCriterion | None = None # duration_manual > 0
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: sessions for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.device is not None:
|
||||
q &= self.device.to_q("device_id")
|
||||
if self.emulated is not None:
|
||||
q &= self.emulated.to_q("emulated")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.duration_minutes is not None:
|
||||
c = self.duration_minutes
|
||||
td_val = timedelta(minutes=c.value)
|
||||
field = "duration_total"
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
if self.is_active is not None:
|
||||
if self.is_active.value:
|
||||
q &= Q(timestamp_end__isnull=True)
|
||||
else:
|
||||
q &= Q(timestamp_end__isnull=False)
|
||||
if self.timestamp_start is not None:
|
||||
q &= self.timestamp_start.to_q("timestamp_start")
|
||||
if self.timestamp_end is not None:
|
||||
q &= self.timestamp_end.to_q("timestamp_end")
|
||||
if self.is_manual is not None:
|
||||
if self.is_manual.value:
|
||||
q &= ~Q(duration_manual=timedelta(0))
|
||||
else:
|
||||
q &= Q(duration_manual=timedelta(0))
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(game__name__icontains=self.search.value)
|
||||
| Q(game__platform__name__icontains=self.search.value)
|
||||
| Q(device__name__icontains=self.search.value)
|
||||
| Q(device__type__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: sessions for games matching GameFilter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
# AND / OR / NOT
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PurchaseFilter ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PurchaseFilter(OperatorFilter):
|
||||
"""Filter for the Purchase model."""
|
||||
|
||||
AND: PurchaseFilter | None = None
|
||||
OR: PurchaseFilter | None = None
|
||||
NOT: PurchaseFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # platform_id
|
||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||
date_purchased: StringCriterion | None = None # date string
|
||||
date_refunded: StringCriterion | None = None # date string
|
||||
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
||||
price: FloatCriterion | None = None # on price field
|
||||
converted_price: FloatCriterion | None = None
|
||||
price_currency: StringCriterion | None = None
|
||||
num_purchases: IntCriterion | None = None
|
||||
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
|
||||
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
|
||||
created_at: StringCriterion | None = None
|
||||
updated_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: purchases for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
|
||||
from django.db.models import Q
|
||||
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.platform is not None:
|
||||
q &= self.platform.to_q("platform_id")
|
||||
if self.games is not None:
|
||||
q &= self.games.to_q("games")
|
||||
if self.date_purchased is not None:
|
||||
q &= self.date_purchased.to_q("date_purchased")
|
||||
if self.date_refunded is not None:
|
||||
q &= self.date_refunded.to_q("date_refunded")
|
||||
if self.is_refunded is not None:
|
||||
q &= Q(date_refunded__isnull=not self.is_refunded.value)
|
||||
if self.price is not None:
|
||||
q &= self.price.to_q("price")
|
||||
if self.converted_price is not None:
|
||||
q &= self.converted_price.to_q("converted_price")
|
||||
if self.price_currency is not None:
|
||||
q &= self.price_currency.to_q("price_currency")
|
||||
if self.num_purchases is not None:
|
||||
q &= self.num_purchases.to_q("num_purchases")
|
||||
if self.ownership_type is not None:
|
||||
q &= self.ownership_type.to_q("ownership_type")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(games__name__icontains=self.search.value)
|
||||
| Q(platform__name__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(games__id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── Convenience helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_game_filter(json_str: str) -> GameFilter | None:
|
||||
return filter_from_json(GameFilter, json_str)
|
||||
|
||||
|
||||
def parse_session_filter(json_str: str) -> SessionFilter | None:
|
||||
return filter_from_json(SessionFilter, json_str)
|
||||
|
||||
|
||||
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
|
||||
return filter_from_json(PurchaseFilter, json_str)
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
@@ -0,0 +1,225 @@
|
||||
name,platform,start,end
|
||||
Nioh 2,PS5,2022-12-17 19:34,2022-12-17 22:53
|
||||
Nioh 2,PS5,2022-12-15 02:25,2022-12-15 03:57
|
||||
Nioh 2,PS5,2022-12-13 02:41,2022-12-13 04:34
|
||||
Nioh 2,PS5,2022-12-11 21:01,2022-12-11 23:21
|
||||
VALKYRIE ELYSIUM,PS5,2022-12-11 06:07,2022-12-11 06:20
|
||||
Metal: Hellsinger,PS5,2022-12-11 05:50,2022-12-11 06:07
|
||||
Nioh 2,PS5,2022-12-11 04:31,2022-12-11 05:50
|
||||
Nioh 2,PS5,2022-12-11 04:11,2022-12-11 04:26
|
||||
Forspoken,PS5,2022-12-10 22:29,2022-12-10 23:10
|
||||
Nioh 2,PS5,2022-12-10 19:44,2022-12-10 22:29
|
||||
Nioh 2,PS5,2022-12-09 02:14,2022-12-09 04:16
|
||||
Nioh 2,PS5,2022-12-08 01:03,2022-12-08 01:57
|
||||
Nioh 2,PS5,2022-12-07 00:43,2022-12-07 04:16
|
||||
Nioh 2,PS5,2022-12-04 20:48,2022-12-04 23:31
|
||||
Nioh 2,PS5,2022-12-04 04:26,2022-12-04 07:01
|
||||
Nioh 2,PS5,2022-12-04 04:20,2022-12-04 04:22
|
||||
Nioh 2,PS5,2022-11-26 19:18,2022-11-26 21:28
|
||||
Nioh 2,PS5,2022-11-26 19:16,2022-11-26 19:18
|
||||
Nioh 2,PS5,2022-11-26 02:46,2022-11-26 03:56
|
||||
Nioh 2,PS5,2022-11-26 02:01,2022-11-26 02:43
|
||||
God of War Ragnarök,PS5,2022-11-24 23:03,2022-11-25 01:32
|
||||
God of War Ragnarök,PS5,2022-11-23 00:41,2022-11-23 07:52
|
||||
God of War Ragnarök,PS5,2022-11-21 22:52,2022-11-22 04:51
|
||||
God of War Ragnarök,PS5,2022-11-21 02:11,2022-11-21 05:13
|
||||
God of War Ragnarök,PS5,2022-11-20 21:34,2022-11-20 22:50
|
||||
God of War Ragnarök,PS5,2022-11-20 03:46,2022-11-20 05:52
|
||||
God of War Ragnarök,PS5,2022-11-19 14:30,2022-11-19 16:14
|
||||
God of War Ragnarök,PS5,2022-11-18 23:15,2022-11-19 04:16
|
||||
God of War Ragnarök,PS5,2022-11-18 19:58,2022-11-18 20:40
|
||||
God of War Ragnarök,PS5,2022-11-18 03:50,2022-11-18 06:25
|
||||
God of War Ragnarök,PS5,2022-11-17 19:36,2022-11-18 00:26
|
||||
God of War Ragnarök,PS5,2022-11-17 13:16,2022-11-17 16:13
|
||||
God of War Ragnarök,PS5,2022-11-16 21:45,2022-11-16 22:38
|
||||
God of War Ragnarök,PS5,2022-11-16 00:14,2022-11-16 04:28
|
||||
God of War Ragnarök,PS5,2022-11-15 01:33,2022-11-15 05:06
|
||||
God of War Ragnarök,PS5,2022-11-14 00:37,2022-11-14 04:43
|
||||
God of War Ragnarök,PS5,2022-11-12 23:32,2022-11-13 03:45
|
||||
God of War Ragnarök,PS5,2022-11-12 03:17,2022-11-12 05:00
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-10-03 02:01,2022-10-03 02:23
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-10-02 13:59,2022-10-02 15:54
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-30 22:40,2022-10-01 02:50
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 22:38,2022-09-28 00:16
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 19:27,2022-09-27 21:09
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-26 20:58,2022-09-26 23:38
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 23:56,2022-09-26 02:36
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 14:57,2022-09-25 16:38
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 02:04,2022-09-25 02:12
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-09-23 20:33,2022-09-23 23:38
|
||||
Wo Long: Fallen Dynasty,PS5,2022-09-18 15:26,2022-09-18 16:58
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-08-31 00:42,2022-08-31 01:15
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 13:43,2022-08-18 15:12
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 00:42,2022-08-18 01:58
|
||||
Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-18 00:40,2022-08-18 00:42
|
||||
Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-14 15:46,2022-08-14 16:10
|
||||
FINAL FANTASY VII,PS5,2022-07-26 18:56,2022-07-26 20:22
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-26 17:39,2022-07-26 18:53
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-25 22:12,2022-07-26 04:37
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-24 00:09,2022-07-24 05:33
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-23 23:34,2022-07-23 23:48
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-23 18:05,2022-07-23 19:44
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-22 17:07,2022-07-23 01:48
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-22 15:26,2022-07-22 15:59
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-21 21:27,2022-07-21 22:43
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-21 20:48,2022-07-21 20:58
|
||||
FINAL FANTASY VII REMAKE,PS5,2022-07-21 18:35,2022-07-21 18:36
|
||||
Stray,PS5,2022-07-21 01:24,2022-07-21 02:34
|
||||
Stray,PS5,2022-07-19 23:49,2022-07-20 03:24
|
||||
Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-04 03:26,2022-07-04 03:34
|
||||
Red Dead Redemption,PS3,2022-07-04 02:36,2022-07-04 03:14
|
||||
Ghost of Tsushima,PS5,2022-07-04 01:38,2022-07-04 02:34
|
||||
Dark Cloud™,PS4,2022-07-01 23:48,2022-07-02 00:04
|
||||
Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-01 23:20,2022-07-01 23:46
|
||||
Resident Evil Director’s Cut,PS5,2022-07-01 23:14,2022-07-01 23:20
|
||||
ELEX II,PS5,2022-07-01 22:48,2022-07-01 23:13
|
||||
OlliOlli World,PS5,2022-07-01 21:30,2022-07-01 22:30
|
||||
Deep Rock Galactic,PS5,2022-06-16 05:30,2022-06-16 06:14
|
||||
Curse of the Dead Gods,PS5,2022-06-16 05:00,2022-06-16 05:22
|
||||
Persona 5: Dancing in Starlight,PS5,2022-04-29 20:14,2022-04-29 20:15
|
||||
Persona 5: Dancing in Starlight,PS5,2022-04-29 00:18,2022-04-29 00:44
|
||||
Dying Light 2,PS5,2022-04-14 01:26,2022-04-14 01:27
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-03-24 16:26,2022-03-24 16:27
|
||||
Grand Theft Auto V (PlayStation®5),PS5,2022-03-21 15:52,2022-03-21 15:59
|
||||
Horizon Forbidden West,PS5,2022-02-23 19:37,2022-02-24 00:24
|
||||
Horizon Forbidden West,PS5,2022-02-23 13:57,2022-02-23 17:44
|
||||
Horizon Forbidden West,PS5,2022-02-22 18:05,2022-02-23 05:26
|
||||
Horizon Forbidden West,PS5,2022-02-22 15:39,2022-02-22 17:02
|
||||
Horizon Forbidden West,PS5,2022-02-22 00:05,2022-02-22 04:08
|
||||
Horizon Forbidden West,PS5,2022-02-20 15:39,2022-02-20 23:08
|
||||
Horizon Forbidden West,PS5,2022-02-20 14:54,2022-02-20 15:09
|
||||
Horizon Forbidden West,PS5,2022-02-19 23:37,2022-02-20 04:45
|
||||
Horizon Forbidden West,PS5,2022-02-18 23:15,2022-02-19 03:27
|
||||
Assassin's Creed® Origins,PS5,2022-02-18 21:49,2022-02-18 23:15
|
||||
Assassin's Creed® Origins,PS5,2022-01-17 02:38,2022-01-17 02:50
|
||||
Deep Rock Galactic,PS5,2022-01-17 00:57,2022-01-17 02:35
|
||||
HITMAN 3,PS5,2021-11-17 00:35,2021-11-17 01:17
|
||||
HITMAN 3,PS5,2021-11-08 01:59,2021-11-08 06:17
|
||||
HITMAN 3,PS5,2021-11-07 03:10,2021-11-07 05:23
|
||||
HITMAN 3,PS5,2021-11-06 04:23,2021-11-06 08:49
|
||||
HITMAN 3,PS5,2021-11-06 02:17,2021-11-06 03:31
|
||||
HITMAN 3,PS5,2021-11-05 21:33,2021-11-05 23:24
|
||||
HITMAN 3,PS5,2021-11-05 03:09,2021-11-05 03:34
|
||||
HITMAN 3,PS5,2021-11-05 00:47,2021-11-05 02:26
|
||||
HITMAN 3,PS5,2021-11-04 20:27,2021-11-04 23:32
|
||||
HITMAN 3,PS5,2021-11-04 01:34,2021-11-04 05:33
|
||||
RESIDENT EVIL 3,PS5,2021-11-03 23:14,2021-11-03 23:56
|
||||
RESIDENT EVIL 3,PS5,2021-11-02 23:56,2021-11-03 05:10
|
||||
RESIDENT EVIL 3,PS5,2021-11-02 21:22,2021-11-02 23:23
|
||||
RESIDENT EVIL 3,PS5,2021-11-02 05:36,2021-11-02 06:56
|
||||
HITMAN 3,PS5,2021-11-02 03:00,2021-11-02 05:36
|
||||
HITMAN 3,PS5,2021-11-02 01:19,2021-11-02 01:25
|
||||
HITMAN™ 2,PS5,2021-11-02 01:09,2021-11-02 01:19
|
||||
HITMAN 3,PS5,2021-11-01 23:45,2021-11-02 01:09
|
||||
RESIDENT EVIL 3,PS5,2021-11-01 19:32,2021-11-01 19:47
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-17 01:06,2021-10-17 03:27
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-16 20:58,2021-10-16 22:00
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-05 02:30,2021-10-05 03:27
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 23:12,2021-10-04 01:21
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 03:02,2021-10-03 04:42
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 20:12,2021-10-02 21:10
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 01:40,2021-10-02 03:36
|
||||
Marvel's Spider-Man: Miles Morales,PS5,2021-10-01 04:34,2021-10-01 05:30
|
||||
DEATHLOOP,PS5,2021-10-01 01:12,2021-10-01 04:27
|
||||
DEATHLOOP,PS5,2021-09-30 03:04,2021-09-30 06:30
|
||||
DEATHLOOP,PS5,2021-09-29 00:28,2021-09-29 05:08
|
||||
Persona 5 Royal,PS5,2021-09-28 00:36,2021-09-28 03:08
|
||||
Persona 5 Royal,PS5,2021-09-27 02:16,2021-09-27 05:56
|
||||
Persona 5 Royal,PS5,2021-09-26 14:54,2021-09-26 16:32
|
||||
Persona 5 Royal,PS5,2021-09-25 18:43,2021-09-25 23:26
|
||||
Persona 5 Royal,PS5,2021-09-24 21:41,2021-09-25 03:40
|
||||
Persona 5 Royal,PS5,2021-09-23 00:18,2021-09-23 06:26
|
||||
Persona 5 Royal,PS5,2021-09-21 20:27,2021-09-22 05:43
|
||||
Persona 5 Royal,PS5,2021-09-21 01:07,2021-09-21 06:06
|
||||
Borderlands: The Handsome Collection,PS5,2021-09-20 23:59,2021-09-21 01:07
|
||||
Persona 5 Royal,PS5,2021-09-20 23:53,2021-09-20 23:59
|
||||
DEATHLOOP,PS5,2021-09-20 02:03,2021-09-20 06:29
|
||||
DEATHLOOP,PS5,2021-09-19 19:49,2021-09-20 01:16
|
||||
Borderlands: The Handsome Collection,PS5,2021-09-19 00:51,2021-09-19 03:41
|
||||
Borderlands: The Handsome Collection,PS5,2021-09-17 23:45,2021-09-18 01:48
|
||||
Borderlands: The Handsome Collection,PS5,2021-09-17 23:40,2021-09-17 23:41
|
||||
DEATHLOOP,PS5,2021-09-17 16:48,2021-09-17 18:56
|
||||
DEATHLOOP,PS5,2021-09-17 03:02,2021-09-17 04:39
|
||||
DEATHLOOP,PS5,2021-09-17 00:03,2021-09-17 02:53
|
||||
DEATHLOOP,PS5,2021-09-16 18:39,2021-09-16 21:12
|
||||
Persona 5 Royal,PS5,2021-09-16 18:29,2021-09-16 18:30
|
||||
Persona 5 Royal,PS5,2021-09-16 02:26,2021-09-16 06:13
|
||||
Persona 5 Royal,PS5,2021-09-16 02:20,2021-09-16 02:21
|
||||
Persona 5 Royal,PS5,2021-09-15 01:48,2021-09-15 06:07
|
||||
Persona 5 Royal,PS5,2021-09-14 22:21,2021-09-15 01:22
|
||||
Persona 5 Royal,PS5,2021-09-14 02:01,2021-09-14 05:48
|
||||
Persona 5 Royal,PS5,2021-09-14 00:24,2021-09-14 01:46
|
||||
Persona 5 Royal,PS5,2021-08-12 05:04,2021-08-12 07:05
|
||||
Persona 5 Royal,PS5,2021-08-11 05:02,2021-08-11 06:48
|
||||
Persona 5 Royal,PS5,2021-08-09 00:37,2021-08-09 06:15
|
||||
Persona 5 Royal,PS5,2021-08-08 00:31,2021-08-08 08:01
|
||||
Persona 5 Royal,PS5,2021-08-07 19:51,2021-08-07 22:50
|
||||
Persona 5 Royal,PS5,2021-08-06 23:51,2021-08-07 01:35
|
||||
Persona 5 Royal,PS5,2021-08-06 19:26,2021-08-06 22:26
|
||||
Persona 5 Royal,PS5,2021-08-06 02:42,2021-08-06 06:51
|
||||
Persona 5 Royal,PS5,2021-08-06 00:37,2021-08-06 01:54
|
||||
Far Cry® 5,PS5,2021-08-01 23:27,2021-08-02 02:09
|
||||
Far Cry® 5,PS5,2021-08-01 18:10,2021-08-01 19:40
|
||||
STAR WARS™: Squadrons,PS5,2021-08-01 18:02,2021-08-01 18:10
|
||||
STAR WARS™: Squadrons,PS5,2021-08-01 00:24,2021-08-01 00:30
|
||||
STEEP,PS5,2021-08-01 00:15,2021-08-01 00:24
|
||||
Red Dead Redemption 2,PS5,2021-07-31 23:48,2021-08-01 00:13
|
||||
Persona 5 Royal,PS5,2021-07-30 19:09,2021-07-30 19:10
|
||||
Persona 5 Royal,PS5,2021-07-29 03:41,2021-07-29 04:59
|
||||
Persona 5 Royal,PS5,2021-07-28 02:32,2021-07-28 03:07
|
||||
Demon's Souls,PS5,2021-07-28 00:12,2021-07-28 02:32
|
||||
Red Dead Redemption 2,PS5,2021-07-27 23:20,2021-07-27 23:23
|
||||
Red Dead Redemption 2,PS5,2021-07-26 00:42,2021-07-26 01:13
|
||||
Ghost of Tsushima,PS5,2021-07-25 19:03,2021-07-25 22:12
|
||||
Ghost of Tsushima,PS5,2021-07-25 18:52,2021-07-25 18:55
|
||||
Rez Infinite,PS5,2021-07-25 18:32,2021-07-25 18:52
|
||||
Returnal,PS5,2021-07-25 05:24,2021-07-25 05:26
|
||||
Tom Clancy's The Division® 2,PS5,2021-07-25 02:12,2021-07-25 05:24
|
||||
Returnal,PS5,2021-07-25 00:00,2021-07-25 02:12
|
||||
Returnal,PS5,2021-07-24 16:13,2021-07-24 17:39
|
||||
Returnal,PS5,2021-07-24 03:02,2021-07-24 07:02
|
||||
Returnal,PS5,2021-07-23 18:08,2021-07-23 20:39
|
||||
Returnal,PS5,2021-07-23 14:36,2021-07-23 15:38
|
||||
Titanfall™ 2,PS5,2021-07-22 02:42,2021-07-22 03:20
|
||||
Returnal,PS5,2021-07-20 02:12,2021-07-20 05:40
|
||||
Returnal,PS5,2021-07-19 03:37,2021-07-19 05:24
|
||||
Concrete Genie,PS5,2021-07-19 03:35,2021-07-19 03:37
|
||||
Concrete Genie,PS5,2021-07-18 05:04,2021-07-18 05:30
|
||||
Stranded Deep,PS5,2021-07-18 04:32,2021-07-18 04:58
|
||||
Sniper Elite 4,PS5,2021-07-18 04:16,2021-07-18 04:32
|
||||
Oddworld: Soulstorm,PS5,2021-07-18 04:00,2021-07-18 04:14
|
||||
Zombie Army 4: Dead War,PS5,2021-07-18 03:48,2021-07-18 03:58
|
||||
Sekiro™: Shadows Die Twice,PS5,2021-07-18 03:00,2021-07-18 03:48
|
||||
Returnal,PS5,2021-07-17 17:41,2021-07-17 23:32
|
||||
Returnal,PS5,2021-07-17 01:35,2021-07-17 06:07
|
||||
Returnal,PS5,2021-07-17 00:23,2021-07-17 01:21
|
||||
Another World - 20th Anniversary Edition,PS5,2021-07-15 22:09,2021-07-15 23:33
|
||||
Sekiro™: Shadows Die Twice,PS5,2021-07-15 21:52,2021-07-15 22:09
|
||||
Sekiro™: Shadows Die Twice,PS5,2021-07-15 19:07,2021-07-15 20:13
|
||||
Sekiro™: Shadows Die Twice,PS5,2021-07-15 01:50,2021-07-15 03:31
|
||||
Sekiro™: Shadows Die Twice,PS5,2021-07-15 00:12,2021-07-15 01:14
|
||||
Sekiro™: Shadows Die Twice,PS5,2021-07-14 03:19,2021-07-14 05:13
|
||||
Persona 5,PS5,2021-07-13 21:56,2021-07-13 21:58
|
||||
Persona 5,PS5,2021-07-13 01:32,2021-07-13 02:59
|
||||
Maquette,PS5,2021-07-13 01:30,2021-07-13 01:32
|
||||
Maquette,PS5,2021-07-12 23:59,2021-07-13 00:34
|
||||
ASTRO's PLAYROOM,PS5,2021-07-12 23:06,2021-07-12 23:59
|
||||
Crash Bandicoot N. Sane Trilogy,PS5,2021-07-12 23:01,2021-07-12 23:06
|
||||
Virtua Fighter 5 Ultimate Showdown,PS4,2021-07-02 19:46,2021-07-02 20:57
|
||||
Bloodborne™,PS4,2021-04-03 19:50,2021-04-03 23:52
|
||||
Tom Clancy's The Division® 2,PS4,2021-04-03 02:10,2021-04-03 05:22
|
||||
Bloodborne™,PS4,2021-04-02 21:34,2021-04-03 02:10
|
||||
Bloodborne™,PS4,2021-04-02 06:01,2021-04-02 08:11
|
||||
Tom Clancy's The Division® 2,PS4,2021-04-02 04:24,2021-04-02 06:01
|
||||
Tom Clancy's The Division® 2,PS4,2021-03-31 02:55,2021-03-31 05:50
|
||||
Tom Clancy's The Division® 2,PS4,2021-03-29 02:00,2021-03-29 02:02
|
||||
Tom Clancy's The Division® 2,PS4,2021-03-27 03:31,2021-03-27 06:29
|
||||
Tom Clancy's The Division® 2,PS4,2021-03-26 05:10,2021-03-26 06:03
|
||||
Remnant: From the Ashes,PS4,2021-03-23 03:20,2021-03-23 05:58
|
||||
DARK SOULS™ II: Scholar of the First Sin,PS4,2021-03-20 01:31,2021-03-20 01:34
|
||||
Remnant: From the Ashes,PS4,2021-03-12 01:29,2021-03-12 01:30
|
||||
Remnant: From the Ashes,PS4,2021-03-08 02:41,2021-03-08 05:38
|
||||
Remnant: From the Ashes,PS4,2021-03-07 03:21,2021-03-07 06:49
|
||||
13 Sentinels: Aegis Rim,PS4,2021-03-07 03:20,2021-03-07 03:21
|
||||
DARK SOULS™ II: Scholar of the First Sin,PS4,2020-10-24 23:43,2020-10-25 01:18
|
||||
Ghost of Tsushima,PS4,2020-10-24 23:14,2020-10-24 23:22
|
||||
|
@@ -0,0 +1,71 @@
|
||||
- model: games.game
|
||||
pk: 1
|
||||
fields:
|
||||
name: Nioh 2
|
||||
wikidata: Q67482292
|
||||
- model: games.game
|
||||
pk: 2
|
||||
fields:
|
||||
name: Elden Ring
|
||||
wikidata: Q64826862
|
||||
- model: games.game
|
||||
pk: 3
|
||||
fields:
|
||||
name: Cyberpunk 2077
|
||||
wikidata: Q3182559
|
||||
- model: games.purchase
|
||||
pk: 1
|
||||
fields:
|
||||
game: 1
|
||||
platform: 1
|
||||
date_purchased: 2021-02-13
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 2
|
||||
fields:
|
||||
game: 2
|
||||
platform: 1
|
||||
date_purchased: 2022-02-24
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 3
|
||||
fields:
|
||||
game: 3
|
||||
platform: 1
|
||||
date_purchased: 2020-12-07
|
||||
date_refunded: null
|
||||
- model: games.platform
|
||||
pk: 1
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
- model: games.platform
|
||||
pk: 3
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
- model: games.platform
|
||||
pk: 4
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
- model: games.platform
|
||||
pk: 5
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
- model: games.platform
|
||||
pk: 6
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
- model: games.platform
|
||||
pk: 7
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
- model: games.platform
|
||||
pk: 8
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
@@ -0,0 +1,268 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
|
||||
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):
|
||||
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"), required=False)
|
||||
|
||||
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 = [
|
||||
"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 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):
|
||||
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",
|
||||
}
|
||||
)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
|
||||
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 = [
|
||||
"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")
|
||||
|
||||
# Set the type on the instance to use get_type_display()
|
||||
# This is safe because we're not saving the instance.
|
||||
self.instance.type = purchase_type
|
||||
|
||||
if purchase_type != Purchase.GAME:
|
||||
type_display = self.instance.get_type_display()
|
||||
if not related_purchase:
|
||||
self.add_error(
|
||||
"related_purchase",
|
||||
f"{type_display} must have a related purchase.",
|
||||
)
|
||||
if not name:
|
||||
self.add_error("name", f"{type_display} must have a name.")
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class IncludeNameSelect(forms.Select):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if value:
|
||||
option["attrs"]["data-name"] = value.instance.name
|
||||
option["attrs"]["data-year"] = value.instance.year_released
|
||||
return option
|
||||
|
||||
|
||||
class GameModelChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
# Use sort_name as the label for the option
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Game
|
||||
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",
|
||||
"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."))
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
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'), ('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',
|
||||
fields=[
|
||||
('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='ExchangeRate',
|
||||
fields=[
|
||||
('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',
|
||||
fields=[
|
||||
('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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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=''),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-17 07:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='game',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-19 13:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Min
|
||||
|
||||
|
||||
def copy_year_released(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.update(original_year_released=F("year_released"))
|
||||
|
||||
|
||||
def set_abandoned_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game = apps.get_model("games", "Game")
|
||||
PlayEvent = apps.get_model("games", "PlayEvent")
|
||||
|
||||
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
|
||||
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
|
||||
|
||||
finished = Game.objects.filter(purchases__date_finished__isnull=False)
|
||||
|
||||
for game in finished:
|
||||
for purchase in game.purchases.all():
|
||||
first_session = game.sessions.filter(
|
||||
timestamp_start__gte=purchase.date_purchased
|
||||
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
|
||||
first_session_date = first_session.date() if first_session else None
|
||||
if purchase.date_finished:
|
||||
play_event = PlayEvent(
|
||||
game=game,
|
||||
started=first_session_date
|
||||
if first_session_date
|
||||
else purchase.date_purchased,
|
||||
ended=purchase.date_finished,
|
||||
)
|
||||
play_event.save()
|
||||
|
||||
|
||||
def create_game_status_changes(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
GameStatusChange = apps.get_model("games", "GameStatusChange")
|
||||
|
||||
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
|
||||
for game in Game.objects.filter(sessions__isnull=False).distinct():
|
||||
if game.sessions.exists():
|
||||
earliest_session = game.sessions.earliest()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="u",
|
||||
new_status="p",
|
||||
timestamp=earliest_session.timestamp_start,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_dropped,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_refunded,
|
||||
)
|
||||
|
||||
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
|
||||
# consider only the first playevent
|
||||
for game in Game.objects.filter(playevents__isnull=False):
|
||||
first_playevent = game.playevents.first()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="f",
|
||||
timestamp=first_playevent.ended,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0007_game_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="original_year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.RunPython(copy_year_released),
|
||||
migrations.CreateModel(
|
||||
name="GameStatusChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"old_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"new_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
("timestamp", models.DateTimeField(null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="status_changes",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("started", models.DateField(blank=True, null=True)),
|
||||
("ended", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"days_to_finish",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.RawSQL(
|
||||
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
),
|
||||
),
|
||||
("note", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="playevents",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(set_abandoned_status),
|
||||
migrations.RunPython(create_game_status_changes),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-20 11:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_dropped',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_finished',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0009_remove_purchase_date_dropped_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0010_remove_purchase_price_per_game'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:30
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0011_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.functions.comparison.Coalesce(
|
||||
django.db.models.expressions.CombinedExpression(
|
||||
models.F("timestamp_end"), "-", models.F("timestamp_start")
|
||||
),
|
||||
0,
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:33
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Sum
|
||||
|
||||
|
||||
def calculate_game_playtime(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
games = Game.objects.all()
|
||||
for game in games:
|
||||
total_playtime = game.sessions.aggregate(
|
||||
total_playtime=Sum(F("duration_total"))
|
||||
)["total_playtime"]
|
||||
if total_playtime:
|
||||
game.playtime = total_playtime
|
||||
game.save(update_fields=["playtime"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0012_alter_session_duration_calculated"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="playtime",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), editable=False
|
||||
),
|
||||
),
|
||||
migrations.RunPython(calculate_game_playtime),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:46
|
||||
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0013_game_playtime'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='session',
|
||||
name='duration_total',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.1.7 on 2026-01-15 15:37
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0014_session_duration_total'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_purchased',
|
||||
field=models.DateField(verbose_name='Purchased'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_refunded',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='duration_manual',
|
||||
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_end',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(verbose_name='Start'),
|
||||
),
|
||||
]
|
||||
@@ -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 != ''",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 07:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0016_add_needs_price_update'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FilterPreset',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)),
|
||||
('find_filter', models.JSONField(blank=True, default=dict)),
|
||||
('object_filter', models.JSONField(blank=True, default=dict)),
|
||||
('ui_options', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,510 @@
|
||||
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
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
class Meta:
|
||||
unique_together = [["name", "platform", "year_released"]]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
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())
|
||||
|
||||
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)
|
||||
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"
|
||||
DIGITAL = "di"
|
||||
DIGITALUPGRADE = "du"
|
||||
RENTED = "re"
|
||||
BORROWED = "bo"
|
||||
TRIAL = "tr"
|
||||
DEMO = "de"
|
||||
PIRATED = "pi"
|
||||
OWNERSHIP_TYPES = [
|
||||
(PHYSICAL, "Physical"),
|
||||
(DIGITAL, "Digital"),
|
||||
(DIGITALUPGRADE, "Digital Upgrade"),
|
||||
(RENTED, "Rented"),
|
||||
(BORROWED, "Borrowed"),
|
||||
(TRIAL, "Trial"),
|
||||
(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"),
|
||||
]
|
||||
|
||||
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):
|
||||
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)})"
|
||||
|
||||
def is_game(self):
|
||||
return self.type == self.GAME
|
||||
|
||||
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_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 result["duration"]
|
||||
|
||||
def calculated_duration_formatted(self):
|
||||
return format_duration(self.calculated_duration_unformatted())
|
||||
|
||||
def calculated_duration_unformatted(self):
|
||||
result = self.aggregate(duration=Sum(F("duration_calculated")))
|
||||
return result["duration"]
|
||||
|
||||
def without_manual(self):
|
||||
return self.exclude(duration_calculated__iexact=0)
|
||||
|
||||
def only_manual(self):
|
||||
return self.filter(duration_calculated__iexact=0)
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
class 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 = "*" 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 = timezone.now()
|
||||
|
||||
def duration_formatted(self) -> str:
|
||||
result = format_duration(self.duration_total, "%02.1H")
|
||||
return result
|
||||
|
||||
def duration_formatted_with_mark(self) -> str:
|
||||
mark = "*" if self.is_manual() else ""
|
||||
return f"{self.duration_formatted()}{mark}"
|
||||
|
||||
def is_manual(self) -> bool:
|
||||
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 = "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=255, choices=DEVICE_TYPES, default=UNKNOWN)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
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"]
|
||||
|
||||
|
||||
class FilterPreset(models.Model):
|
||||
"""Saved filter configuration, following Stash's SavedFilter pattern.
|
||||
|
||||
Separates find_filter (sort/pagination), object_filter (criteria JSON),
|
||||
and ui_options (presentation state) so they can evolve independently.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
MODE_CHOICES = [
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
mode = models.CharField(max_length=50, choices=MODE_CHOICES, default="games")
|
||||
find_filter = models.JSONField(default=dict, blank=True)
|
||||
object_filter = models.JSONField(default=dict, blank=True)
|
||||
ui_options = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_mode_display()})"
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 787 B |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
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,380 @@
|
||||
/**
|
||||
* Filter bar — vanilla JavaScript implementation.
|
||||
*
|
||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
||||
* No HTMX — plain fetch() and window.location for all interactions.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/** Build a criterion object from a value and optional second value. */
|
||||
function criterion(value, value2, modifier) {
|
||||
var c = { value: value, modifier: modifier };
|
||||
if (value2 !== null && value2 !== undefined && value2 !== "") {
|
||||
c.value2 = value2;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Read a <select> element's value, or "" if not found. */
|
||||
function selectValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
/** Read an <input type="number"> value, or "" if not found. */
|
||||
function numberValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
if (!el || el.value === "") return "";
|
||||
var val = parseFloat(el.value);
|
||||
return isNaN(val) ? "" : val;
|
||||
}
|
||||
|
||||
/** Read all checked checkboxes with a given name, returning an array of ints. */
|
||||
function checkedValues(form, name) {
|
||||
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
||||
var ids = [];
|
||||
els.forEach(function (el) {
|
||||
var v = parseInt(el.value, 10);
|
||||
if (!isNaN(v)) ids.push(v);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the filter JSON object from form field values.
|
||||
* Returns a plain object ready for JSON.stringify.
|
||||
*/
|
||||
function buildFilterJSON(form) {
|
||||
// Read all SelectableFilter widgets first
|
||||
readSelectableFilters(form);
|
||||
|
||||
var filter = {};
|
||||
var yearMin = numberValue(form, "filter-year-min");
|
||||
var yearMax = numberValue(form, "filter-year-max");
|
||||
var playMin = numberValue(form, "filter-playtime-min");
|
||||
var playMax = numberValue(form, "filter-playtime-max");
|
||||
var mastered = form.querySelector('[name="filter-mastered"]');
|
||||
|
||||
// ── Search field ──
|
||||
var searchInput = form.querySelector('[name="filter-search"]');
|
||||
if (searchInput && searchInput.value.trim()) {
|
||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||
}
|
||||
|
||||
// ── Generic SelectableFilter widgets ──
|
||||
readSelectableFilters(form);
|
||||
var widgets = form.querySelectorAll("[data-selectable-filter]");
|
||||
widgets.forEach(function (w) {
|
||||
var field = w.getAttribute("data-selectable-filter");
|
||||
var inc = parseJSONAttr(w, "data-included");
|
||||
var exc = parseJSONAttr(w, "data-excluded");
|
||||
var mod = w.getAttribute("data-modifier");
|
||||
if (mod === "NOT_NULL" || mod === "IS_NULL") {
|
||||
filter[field] = { modifier: mod };
|
||||
} else if (inc.length > 0 || exc.length > 0) {
|
||||
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
|
||||
filter[field] = {
|
||||
value: isIdField ? inc.map(Number) : inc,
|
||||
excludes: isIdField ? exc.map(Number) : exc,
|
||||
modifier: mod || "INCLUDES",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Session-specific fields ──
|
||||
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
|
||||
|
||||
// Game (sessions page)
|
||||
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
|
||||
if (gameWidget) {
|
||||
var gIncluded = parseJSONAttr(gameWidget, "data-included");
|
||||
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
|
||||
var gMod = gameWidget.getAttribute("data-modifier");
|
||||
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
|
||||
filter.game = { modifier: gMod };
|
||||
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
|
||||
filter.game = {
|
||||
value: gIncluded.map(Number),
|
||||
excludes: gExcluded.map(Number),
|
||||
modifier: gMod || "INCLUDES",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Device (sessions page)
|
||||
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
|
||||
if (deviceWidget) {
|
||||
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
|
||||
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
|
||||
var dMod = deviceWidget.getAttribute("data-modifier");
|
||||
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
|
||||
filter.device = { modifier: dMod };
|
||||
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
|
||||
filter.device = {
|
||||
value: dIncluded.map(Number),
|
||||
excludes: dExcluded.map(Number),
|
||||
modifier: dMod || "INCLUDES",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Emulated checkbox (sessions page)
|
||||
var emulated = form.querySelector('[name="filter-emulated"]');
|
||||
if (emulated && emulated.checked) {
|
||||
filter.emulated = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
// Active checkbox (sessions page)
|
||||
var active = form.querySelector('[name="filter-active"]');
|
||||
if (active && active.checked) {
|
||||
filter.is_active = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
if (yearMin !== "" && yearMax !== "") {
|
||||
// Skip if both equal the data range extremes (no real filter)
|
||||
var yrMinNum = parseInt(yearMin, 10);
|
||||
var yrMaxNum = parseInt(yearMax, 10);
|
||||
if (yrMinNum === yrMaxNum) {
|
||||
// don't add filter
|
||||
} else {
|
||||
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
|
||||
}
|
||||
} else if (yearMin !== "") {
|
||||
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
|
||||
} else if (yearMax !== "") {
|
||||
filter.year_released = criterion(yearMax, null, "LESS_THAN");
|
||||
}
|
||||
|
||||
if (playMin !== "" || playMax !== "") {
|
||||
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
|
||||
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
|
||||
// Skip if both are 0 — means slider is at default (no real filter)
|
||||
if (pMin === 0 && pMax === 0) {
|
||||
// don't add filter
|
||||
} else {
|
||||
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
|
||||
if (playMin !== "" && playMax !== "") {
|
||||
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
|
||||
} else if (playMin !== "") {
|
||||
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
|
||||
} else if (playMax !== "") {
|
||||
filter[durKey] = criterion(pMax, null, "LESS_THAN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mastered && mastered.checked) {
|
||||
filter.mastered = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** Extract the current page's base URL (without query string). */
|
||||
function baseUrl() {
|
||||
return window.location.pathname;
|
||||
}
|
||||
|
||||
/** Safely parse a JSON attribute, returning empty array on failure. */
|
||||
function parseJSONAttr(el, attr) {
|
||||
var raw = el.getAttribute(attr);
|
||||
if (!raw) return [];
|
||||
try { return JSON.parse(raw); } catch (e) { return []; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on filter bar form submit.
|
||||
* Serializes filter fields, navigates to URL with filter param.
|
||||
*/
|
||||
window.applyFilterBar = function (event) {
|
||||
event.preventDefault();
|
||||
var form = event.target;
|
||||
var filter = buildFilterJSON(form);
|
||||
var filterStr = JSON.stringify(filter);
|
||||
var url = baseUrl();
|
||||
if (filterStr && filterStr !== "{}") {
|
||||
url += "?filter=" + encodeURIComponent(filterStr);
|
||||
}
|
||||
window.location.href = url;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all filter fields and reload the unfiltered view.
|
||||
*/
|
||||
window.clearFilterBar = function (formId, filterInputId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
window.location.href = baseUrl();
|
||||
};
|
||||
|
||||
// ── Presets ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch and render the preset list. */
|
||||
function loadPresets() {
|
||||
var dropdown = document.getElementById("preset-dropdown");
|
||||
if (!dropdown) return;
|
||||
var url = dropdown.getAttribute("data-preset-list-url");
|
||||
if (!url) return;
|
||||
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
|
||||
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed to load presets");
|
||||
return r.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
dropdown.innerHTML = html;
|
||||
// Re-attach delete handlers (list_presets view uses onclick attributes,
|
||||
// but we also need to wire up inline handlers if they use data attributes)
|
||||
setupPresetDeleteHandlers(dropdown);
|
||||
})
|
||||
.catch(function (err) {
|
||||
dropdown.innerHTML =
|
||||
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire up click handlers for preset delete buttons. */
|
||||
function setupPresetDeleteHandlers(container) {
|
||||
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
|
||||
deleteLinks.forEach(function (link) {
|
||||
link.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var presetId = link.getAttribute("data-delete-preset");
|
||||
var deleteUrl = link.getAttribute("href");
|
||||
if (!deleteUrl) return;
|
||||
if (!confirm("Delete this preset?")) return;
|
||||
fetch(deleteUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRFToken": getCsrfToken() },
|
||||
})
|
||||
.then(function () {
|
||||
// Remove the parent <li>
|
||||
var li = link.closest("li");
|
||||
if (li) li.remove();
|
||||
// If no items left, show empty message
|
||||
var ul = container.querySelector("ul");
|
||||
if (ul && ul.querySelectorAll("li").length === 0) {
|
||||
ul.innerHTML =
|
||||
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Delete failed:", err);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the preset name input field and the confirm button. */
|
||||
window.showPresetNameInput = function () {
|
||||
var input = document.getElementById("preset-name-input");
|
||||
var saveBtn = document.getElementById("save-preset-btn");
|
||||
var confirmBtn = document.getElementById("confirm-save-preset-btn");
|
||||
if (input) input.classList.remove("hidden");
|
||||
if (saveBtn) saveBtn.classList.add("hidden");
|
||||
if (confirmBtn) confirmBtn.classList.remove("hidden");
|
||||
if (input) input.focus();
|
||||
};
|
||||
|
||||
/** Save the current filter as a named preset. */
|
||||
window.savePreset = function (formId, filterInputId, saveUrl) {
|
||||
var input = document.getElementById("preset-name-input");
|
||||
var name = input ? input.value.trim() : "";
|
||||
if (!name) {
|
||||
if (input) input.classList.add("border-red-500");
|
||||
return;
|
||||
}
|
||||
|
||||
var filterInput = document.getElementById(filterInputId);
|
||||
var form = document.getElementById(formId);
|
||||
var filterObj = form ? buildFilterJSON(form) : {};
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.append("name", name);
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
body.append("mode", mode);
|
||||
body.append("filter", JSON.stringify(filterObj));
|
||||
|
||||
fetch(saveUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRFToken": getCsrfToken(),
|
||||
},
|
||||
body: body.toString(),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Save failed");
|
||||
// Reset UI
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.classList.add("hidden");
|
||||
input.classList.remove("border-red-500");
|
||||
}
|
||||
var saveBtn = document.getElementById("save-preset-btn");
|
||||
var confirmBtn = document.getElementById("confirm-save-preset-btn");
|
||||
if (saveBtn) saveBtn.classList.remove("hidden");
|
||||
if (confirmBtn) confirmBtn.classList.add("hidden");
|
||||
// Refresh the preset list
|
||||
loadPresets();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Failed to save preset:", err);
|
||||
});
|
||||
};
|
||||
|
||||
/** Extract CSRF token from the page. */
|
||||
function getCsrfToken() {
|
||||
var cookie = document.cookie
|
||||
.split("; ")
|
||||
.find(function (row) {
|
||||
return row.startsWith("csrftoken=");
|
||||
});
|
||||
if (cookie) return cookie.split("=")[1];
|
||||
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
// ── Init on page load ───────────────────────────────────────────────────
|
||||
|
||||
// ── Inject search inputs into filter forms ──
|
||||
function injectSearchInputs() {
|
||||
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search\u2026";
|
||||
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||
// Pre-fill from existing filter JSON
|
||||
var hidden = form.querySelector('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
var existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
});
|
||||
}
|
||||
injectSearchInputs();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
loadPresets();
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Dual-handle range slider — pure JS with draggable handles.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll(force) {
|
||||
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
||||
if (force) slider._rsInit = false;
|
||||
if (slider._rsInit) return;
|
||||
slider._rsInit = true;
|
||||
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
var track = slider.querySelector(".range-track-fill");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
|
||||
var minTarget = document.getElementById(minHandle.getAttribute("data-target"));
|
||||
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
|
||||
var dMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
|
||||
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
|
||||
function percentToValue(p) {
|
||||
var raw = dMin + (p / 100) * (dMax - dMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
|
||||
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
|
||||
function setTargetVal(el, v) { if (el) el.value = v; }
|
||||
|
||||
function update() {
|
||||
var minV = getTargetVal(minTarget);
|
||||
var maxV = getTargetVal(maxTarget);
|
||||
minV = clamp(minV, dMin, dMax);
|
||||
maxV = clamp(maxV, dMin, dMax);
|
||||
if (minV > maxV) minV = maxV;
|
||||
if (maxV < minV) maxV = minV;
|
||||
setTargetVal(minTarget, minV);
|
||||
setTargetVal(maxTarget, maxV);
|
||||
var minP = valueToPercent(minV);
|
||||
var maxP = valueToPercent(maxV);
|
||||
minHandle.style.left = minP + "%";
|
||||
maxHandle.style.left = maxP + "%";
|
||||
if (track) {
|
||||
track.style.left = minP + "%";
|
||||
track.style.width = (maxP - minP) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function makeDraggable(handle, isMin) {
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
var rect = slider.getBoundingClientRect();
|
||||
function onMove(ev) {
|
||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
var v = percentToValue(clamp(pct, 0, 100));
|
||||
if (isMin) {
|
||||
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
|
||||
} else {
|
||||
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
|
||||
}
|
||||
update();
|
||||
// Trigger input event on the target so any listeners fire
|
||||
var tgt = isMin ? minTarget : maxTarget;
|
||||
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(e);
|
||||
});
|
||||
}
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// Sync from inputs to slider
|
||||
function fromInputs() { update(); }
|
||||
if (minTarget) minTarget.addEventListener("input", fromInputs);
|
||||
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
|
||||
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
// Expose for manual re-init (filter bar toggle)
|
||||
window.initRangeSliders = initAll;
|
||||
})();
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* SelectableFilter widget — Stash-style choice filter with search,
|
||||
* include/exclude buttons, and modifier tags (Any / None).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
|
||||
if (el._sfInit) return;
|
||||
el._sfInit = true;
|
||||
initWidget(el);
|
||||
});
|
||||
}
|
||||
|
||||
function initWidget(container) {
|
||||
var search = container.querySelector(".sf-search");
|
||||
var options = container.querySelector(".sf-options");
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
if (!search || !options || !selectedArea) return;
|
||||
|
||||
// ── Search ──
|
||||
search.addEventListener("input", function () {
|
||||
var q = search.value.toLowerCase();
|
||||
options.querySelectorAll(".sf-option").forEach(function (item) {
|
||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// ── Include / Exclude clicks ──
|
||||
options.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest("button");
|
||||
if (btn) {
|
||||
var action = btn.getAttribute("data-action");
|
||||
var itemEl = btn.closest(".sf-option");
|
||||
if (!itemEl) return;
|
||||
var value = itemEl.getAttribute("data-value");
|
||||
var label = itemEl.getAttribute("data-label");
|
||||
if (!value) return;
|
||||
if (action === "include") addTag(container, value, label, "include");
|
||||
else if (action === "exclude") addTag(container, value, label, "exclude");
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on modifier option (not a button)
|
||||
var modOption = e.target.closest(".sf-modifier-option");
|
||||
if (modOption) {
|
||||
var modVal = modOption.getAttribute("data-modifier");
|
||||
setModifier(container, modVal);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Remove selected tag ──
|
||||
selectedArea.addEventListener("click", function (e) {
|
||||
var removeBtn = e.target.closest(".sf-remove");
|
||||
if (removeBtn) {
|
||||
removeBtn.closest(".sf-tag").remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on active modifier tag → deselect it
|
||||
var modTag = e.target.closest(".sf-modifier-tag");
|
||||
if (modTag) {
|
||||
clearModifier(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Add a tag to the selected area and clear modifier. */
|
||||
function addTag(container, value, label, type) {
|
||||
clearModifier(container);
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
// Check if already present
|
||||
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
|
||||
if (existing) {
|
||||
if (existing.getAttribute("data-type") !== type) {
|
||||
existing.setAttribute("data-type", type);
|
||||
existing.classList.toggle("sf-excluded", type === "exclude");
|
||||
var text = existing.querySelector(".sf-tag-text");
|
||||
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
|
||||
tag.setAttribute("data-value", value);
|
||||
tag.setAttribute("data-type", type);
|
||||
tag.innerHTML =
|
||||
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
|
||||
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
|
||||
selectedArea.appendChild(tag);
|
||||
}
|
||||
|
||||
/** Set a modifier (Any / None) — clears all tags. */
|
||||
function setModifier(container, modVal) {
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
// Clear all tags
|
||||
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
|
||||
|
||||
// Clear existing modifier tag
|
||||
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
|
||||
|
||||
// Add new modifier tag
|
||||
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "sf-modifier-tag active";
|
||||
tag.setAttribute("data-modifier", modVal);
|
||||
tag.textContent = label;
|
||||
selectedArea.appendChild(tag);
|
||||
|
||||
container.setAttribute("data-modifier", modVal);
|
||||
}
|
||||
|
||||
/** Clear any active modifier, removing the tag. */
|
||||
function clearModifier(container) {
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
|
||||
container.removeAttribute("data-modifier");
|
||||
}
|
||||
|
||||
// Read selections for form submission
|
||||
window.readSelectableFilters = function (form) {
|
||||
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
|
||||
var modifier = container.getAttribute("data-modifier");
|
||||
var modTag = container.querySelector(".sf-modifier-tag.active");
|
||||
if (modTag) modifier = modTag.getAttribute("data-modifier");
|
||||
|
||||
var included = [];
|
||||
var excluded = [];
|
||||
container.querySelectorAll(".sf-tag").forEach(function (tag) {
|
||||
var val = tag.getAttribute("data-value");
|
||||
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
|
||||
else included.push(val);
|
||||
});
|
||||
|
||||
container.setAttribute("data-included", JSON.stringify(included));
|
||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
})();
|
||||
@@ -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: /
|
||||
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import floatformat
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg class="dark:text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 48 48" class="text-black dark:text-white w-4 h-4">
|
||||
<path fill="currentColor" d="M 43.113281 22.152344 C 43.113281 22.152344 47.058594 22.351563 47.058594 20.03125 C 47.058594 16.996094 41.804688 14.261719 41.804688 14.261719 C 41.804688 14.261719 42.628906 12.515625 43.140625 11.539063 C 43.65625 10.5625 45.101563 6.753906 45.230469 5.886719 C 45.394531 4.792969 45.144531 4.449219 45.144531 4.449219 C 44.789063 6.792969 40.972656 13.539063 40.671875 13.769531 C 36.949219 12.023438 31.835938 11.539063 31.835938 11.539063 C 31.835938 11.539063 26.832031 1 22.125 1 C 17.457031 1 17.480469 10.023438 17.480469 10.023438 C 17.480469 10.023438 16.160156 7.464844 14.507813 7.464844 C 12.085938 7.464844 11.292969 11.128906 11.292969 15.097656 C 6.511719 15.097656 2.492188 16.164063 2.132813 16.265625 C 1.773438 16.371094 0.644531 17.191406 1.15625 17.089844 C 2.203125 16.753906 7.113281 15.992188 11.410156 16.367188 C 11.648438 20.140625 13.851563 25.054688 13.851563 25.054688 C 13.851563 25.054688 9.128906 31.894531 9.128906 36.78125 C 9.128906 38.066406 9.6875 40.417969 13.078125 40.417969 C 15.917969 40.417969 19.105469 38.710938 19.707031 38.363281 C 19.183594 39.113281 18.796875 40.535156 18.796875 41.191406 C 18.796875 41.726563 19.113281 43.246094 21.304688 43.246094 C 24.117188 43.246094 27.257813 41.089844 27.257813 41.089844 C 27.257813 41.089844 30.222656 46.019531 32.761719 48.28125 C 33.445313 48.890625 34.097656 49 34.097656 49 C 34.097656 49 31.578125 46.574219 28.257813 40.324219 C 31.34375 38.417969 34.554688 33.921875 34.554688 33.921875 C 34.554688 33.921875 34.933594 33.933594 37.863281 33.933594 C 42.453125 33.933594 48.972656 32.96875 48.972656 29.320313 C 48.972656 25.554688 43.113281 22.152344 43.113281 22.152344 Z M 43.625 19.886719 C 43.625 21.21875 42.359375 21.199219 42.359375 21.199219 L 41.394531 21.265625 C 41.394531 21.265625 39.566406 20.304688 38.460938 19.855469 C 38.460938 19.855469 40.175781 17.207031 40.578125 16.46875 C 40.882813 16.644531 43.625 18.363281 43.625 19.886719 Z M 24.421875 6.308594 C 26.578125 6.308594 29.65625 11.402344 29.65625 11.402344 C 29.65625 11.402344 24.851563 10.972656 20.898438 13.296875 C 21.003906 9.628906 22.238281 6.308594 24.421875 6.308594 Z M 15.871094 10.4375 C 16.558594 10.4375 17.230469 11.269531 17.507813 11.976563 C 17.507813 12.445313 17.75 15.171875 17.75 15.171875 L 13.789063 15.023438 C 13.789063 11.449219 15.1875 10.4375 15.871094 10.4375 Z M 15.464844 35.246094 C 13.300781 35.246094 12.851563 34.039063 12.851563 32.953125 C 12.851563 30.496094 14.8125 27.058594 14.8125 27.058594 C 14.8125 27.058594 17.011719 31.683594 20.851563 33.636719 C 18.945313 34.753906 17.375 35.246094 15.464844 35.246094 Z M 22.492188 40.089844 C 20.972656 40.089844 20.789063 39.105469 20.789063 38.878906 C 20.789063 38.171875 21.339844 37.335938 21.339844 37.335938 C 21.339844 37.335938 23.890625 35.613281 24.054688 35.429688 L 25.9375 38.945313 C 25.9375 38.945313 24.007813 40.089844 22.492188 40.089844 Z M 27.226563 38.171875 C 26.300781 36.554688 25.621094 34.867188 25.621094 34.867188 C 25.621094 34.867188 29.414063 35.113281 31.453125 33.007813 C 30.183594 33.578125 28.15625 34.300781 25.800781 34.082031 C 30.726563 29.742188 33.601563 26.597656 36.03125 23.34375 C 35.824219 23.09375 34.710938 22.316406 34.4375 22.1875 C 32.972656 23.953125 27.265625 30.054688 21.984375 33.074219 C 15.292969 29.425781 13.890625 18.691406 13.746094 16.460938 L 17.402344 16.8125 C 17.402344 16.8125 16.027344 19.246094 16.027344 21.039063 C 16.027344 22.828125 16.242188 22.925781 16.242188 22.925781 C 16.242188 22.925781 16.195313 19.800781 18.125 17.390625 C 19.59375 25.210938 21.125 29.21875 22.320313 31.605469 C 22.925781 31.355469 24.058594 30.851563 24.058594 30.851563 C 24.058594 30.851563 20.683594 21.121094 20.871094 14.535156 C 22.402344 13.71875 24.667969 12.875 27.226563 12.875 C 33.957031 12.875 39.367188 15.773438 39.367188 15.773438 L 37.25 18.730469 C 37.25 18.730469 35.363281 15.3125 32.699219 14.703125 C 34.105469 15.753906 35.679688 17.136719 36.496094 19.128906 C 30.917969 16.949219 24.1875 15.796875 22.027344 15.542969 C 21.839844 16.339844 21.863281 17.480469 21.863281 17.480469 C 21.863281 17.480469 30.890625 19.144531 37.460938 22.90625 C 37.414063 31.125 28.460938 37.4375 27.226563 38.171875 Z M 35.777344 32.027344 C 35.777344 32.027344 38.578125 28.347656 38.535156 23.476563 C 38.535156 23.476563 43.0625 26.28125 43.0625 29.015625 C 43.0625 32.074219 35.777344 32.027344 35.777344 32.027344 Z"><title>Battle.net</title></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |