Compare commits
427 Commits
1.2.0
...
3b9c05d674
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b9c05d674 | |||
| 2c2827df47 | |||
| a6384fc003 | |||
| 7f5384de48 | |||
| ffcc4ba0f3 | |||
|
7493f6fc28
|
|||
|
f9b91c5900
|
|||
|
36098374c2
|
|||
| afc16aabbb | |||
| 3ce3356064 | |||
| ed8589a972 | |||
| f4161bf3f4 | |||
| 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 |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Django Time Tracker",
|
||||
"dockerFile": "../devcontainer.Dockerfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"python.pythonPath": "/usr/local/bin/python",
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"terminal.integrated.defaultProfile.linux": "bash"
|
||||
},
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"batisteo.vscode-django",
|
||||
"charliermarsh.ruff",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
},
|
||||
"forwardPorts": [8000],
|
||||
"postCreateCommand": "poetry install && poetry run python manage.py migrate && npm install && make dev",
|
||||
}
|
||||
+9
-1
@@ -5,4 +5,12 @@
|
||||
.venv
|
||||
.vscode
|
||||
node_modules
|
||||
src/timetracker/static/*
|
||||
static
|
||||
.drone.yml
|
||||
.editorconfig
|
||||
.gitignore
|
||||
CHANGELOG.md
|
||||
db.sqlite3
|
||||
docker-compose*
|
||||
Dockerfile
|
||||
Makefile
|
||||
|
||||
+2
-1
@@ -5,11 +5,12 @@ 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-prod
|
||||
|
||||
@@ -15,3 +15,6 @@ 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
|
||||
+7
-2
@@ -1,9 +1,14 @@
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.venv
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
db.sqlite3
|
||||
data/
|
||||
/static/
|
||||
dist/
|
||||
dist/
|
||||
.DS_Store
|
||||
.python-version
|
||||
.direnv
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-python.debugpy",
|
||||
"batisteo.vscode-django",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: Django",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"runserver"
|
||||
],
|
||||
"django": true,
|
||||
"autoStartBrowser": false,
|
||||
"program": "${workspaceFolder}/manage.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+24
-2
@@ -4,8 +4,30 @@
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.typeCheckingMode": "strict",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
},
|
||||
"ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"],
|
||||
"tailwind-fold.supportedLanguages": [
|
||||
"html",
|
||||
"typescriptreact",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"vue-html",
|
||||
"vue",
|
||||
"php",
|
||||
"markdown",
|
||||
"coffeescript",
|
||||
"svelte",
|
||||
"astro",
|
||||
"erb",
|
||||
"django-html"
|
||||
]
|
||||
}
|
||||
|
||||
+167
-2
@@ -1,4 +1,169 @@
|
||||
## Unreleased
|
||||
## 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
|
||||
@@ -33,7 +198,7 @@
|
||||
* Use the same form when editing a session as when adding a session
|
||||
* Change recent session view to current year instead of last 30 days
|
||||
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
|
||||
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||
* Improve session listing (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
handle_path /static/* {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
reverse_proxy backend:8001
|
||||
}
|
||||
}
|
||||
handle_path /static/* {
|
||||
root * /home/timetracker/app/static
|
||||
file_server
|
||||
}
|
||||
handle /robots.txt {
|
||||
root * /home/timetracker/app/games/static
|
||||
file_server
|
||||
}
|
||||
reverse_proxy localhost:8001
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
handle_path /static/* {
|
||||
root * static
|
||||
file_server browse
|
||||
}
|
||||
handle /robots.txt {
|
||||
root * games/static
|
||||
file_server browse
|
||||
}
|
||||
reverse_proxy :8001
|
||||
}
|
||||
+48
-20
@@ -1,27 +1,55 @@
|
||||
FROM node as css
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN npm install && \
|
||||
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
|
||||
|
||||
FROM python:3.10.9-slim-bullseye
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
ENV VERSION_NUMBER 1.1.2
|
||||
ENV PROD 1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN useradd -m --uid 1000 timetracker
|
||||
WORKDIR /home/timetracker/app
|
||||
COPY . /home/timetracker/app/
|
||||
RUN chown -R timetracker:timetracker /home/timetracker/app
|
||||
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
|
||||
COPY entrypoint.sh /
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
|
||||
FROM python:3.14-slim-bookworm
|
||||
|
||||
ENV PROD=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/home/timetracker/app/.venv/bin:$PATH"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libcap2-bin \
|
||||
supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -m --uid 1000 timetracker \
|
||||
&& mkdir -p /var/log/supervisor /etc/supervisor/conf.d /home/timetracker/data \
|
||||
&& chown timetracker:timetracker /var/log/supervisor /home/timetracker/data
|
||||
|
||||
ARG CADDY_VERSION=2.9.1
|
||||
RUN curl -sL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \
|
||||
-o /tmp/caddy.tar.gz && \
|
||||
tar -xzf /tmp/caddy.tar.gz -C /tmp && \
|
||||
mv /tmp/caddy /usr/local/bin/caddy && \
|
||||
rm /tmp/caddy.tar.gz && \
|
||||
chmod +x /usr/local/bin/caddy
|
||||
|
||||
WORKDIR /home/timetracker/app
|
||||
|
||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||
|
||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
USER timetracker
|
||||
ENV PATH="$PATH:/home/timetracker/.local/bin"
|
||||
RUN pip install --no-cache-dir poetry
|
||||
RUN poetry install
|
||||
ENV VERSION_NUMBER=1.7.0
|
||||
|
||||
EXPOSE 8000
|
||||
CMD [ "/entrypoint.sh" ]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,62 +1,88 @@
|
||||
all: css migrate
|
||||
|
||||
initialize: npm css migrate sethookdir loadplatforms
|
||||
initialize: npm css migrate loadplatforms
|
||||
|
||||
HTMLFILES := $(shell find games/templates -type f)
|
||||
PYTHON_VERSION = 3.12
|
||||
|
||||
npm:
|
||||
npm install
|
||||
pnpm install
|
||||
|
||||
css: common/input.css
|
||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
css-dev: css
|
||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
|
||||
pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
makemigrations:
|
||||
poetry run python manage.py makemigrations
|
||||
uv run python manage.py makemigrations
|
||||
|
||||
migrate: makemigrations
|
||||
poetry run python manage.py migrate
|
||||
uv run python manage.py migrate
|
||||
|
||||
init:
|
||||
uv python install $(PYTHON_VERSION)
|
||||
uv sync
|
||||
pnpm install
|
||||
$(MAKE) loadplatforms
|
||||
|
||||
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"
|
||||
|
||||
dev: migrate
|
||||
poetry run python manage.py runserver
|
||||
|
||||
caddy:
|
||||
caddy run --watch
|
||||
|
||||
dev-prod: migrate collectstatic
|
||||
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
|
||||
@npx concurrently \
|
||||
--names "Caddy,Django,Django-Q" \
|
||||
"caddy run --config Caddyfile.dev" \
|
||||
"PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker" \
|
||||
"PROD=1 uv run manage.py qcluster"
|
||||
|
||||
dumpgames:
|
||||
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||
|
||||
loadplatforms:
|
||||
poetry run python manage.py loaddata platforms.yaml
|
||||
uv run python manage.py loaddata platforms.yaml
|
||||
|
||||
loadall:
|
||||
poetry run python manage.py loaddata data.yaml
|
||||
uv run python manage.py loaddata data.yaml
|
||||
|
||||
loadsample:
|
||||
poetry run python manage.py loaddata sample.yaml
|
||||
uv run python manage.py loaddata sample.yaml
|
||||
|
||||
createsuperuser:
|
||||
poetry run python manage.py createsuperuser
|
||||
uv run python manage.py createsuperuser
|
||||
|
||||
shell:
|
||||
poetry run python manage.py shell
|
||||
uv run python manage.py shell
|
||||
|
||||
collectstatic:
|
||||
poetry run python manage.py collectstatic --clear --no-input
|
||||
uv run python manage.py collectstatic --clear --no-input
|
||||
|
||||
poetry.lock: pyproject.toml
|
||||
poetry install
|
||||
uv.lock: pyproject.toml
|
||||
uv sync
|
||||
|
||||
test: poetry.lock
|
||||
poetry run pytest
|
||||
test: uv.lock
|
||||
uv run --with pytest-django pytest
|
||||
|
||||
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:
|
||||
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
cleanstatic:
|
||||
rm -r static/*
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# Timetracker
|
||||
|
||||
A simple game catalogue and play session tracker.
|
||||
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,112 @@
|
||||
"""Server-side HTML component library.
|
||||
|
||||
Split into core / primitives / domain / filters submodules; this package
|
||||
re-exports the public API so ``from common.components import X`` keeps working.
|
||||
"""
|
||||
|
||||
from common.utils import truncate
|
||||
|
||||
from common.components.core import (
|
||||
Component,
|
||||
HTMLAttribute,
|
||||
HTMLTag,
|
||||
_render_element,
|
||||
randomid,
|
||||
)
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
CsrfInput,
|
||||
Div,
|
||||
ExternalScript,
|
||||
H1,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Pill,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
Label,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
YearPicker,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.domain import (
|
||||
GameLink,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
LinkedPurchase,
|
||||
NameWithIcon,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
SessionDeviceSelector,
|
||||
_resolve_name_with_icon,
|
||||
)
|
||||
from common.components.filters import (
|
||||
FilterBar,
|
||||
PurchaseFilterBar,
|
||||
SelectableFilter,
|
||||
SessionFilterBar,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"Component",
|
||||
"HTMLAttribute",
|
||||
"HTMLTag",
|
||||
"_render_element",
|
||||
"randomid",
|
||||
"A",
|
||||
"AddForm",
|
||||
"Button",
|
||||
"ButtonGroup",
|
||||
"CsrfInput",
|
||||
"Div",
|
||||
"ExternalScript",
|
||||
"H1",
|
||||
"Icon",
|
||||
"Input",
|
||||
"Modal",
|
||||
"ModuleScript",
|
||||
"Pill",
|
||||
"Popover",
|
||||
"PopoverTruncated",
|
||||
"SearchField",
|
||||
"SearchSelect",
|
||||
"SearchSelectOption",
|
||||
"searchselect_selected",
|
||||
"SimpleTable",
|
||||
"Span",
|
||||
"Label",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
"YearPicker",
|
||||
"paginated_table_content",
|
||||
"GameLink",
|
||||
"GameStatus",
|
||||
"GameStatusSelector",
|
||||
"LinkedPurchase",
|
||||
"NameWithIcon",
|
||||
"PriceConverted",
|
||||
"PurchasePrice",
|
||||
"SessionDeviceSelector",
|
||||
"_resolve_name_with_icon",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SelectableFilter",
|
||||
"SessionFilterBar",
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Escaping core: the Component builder and its memoised renderer."""
|
||||
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
|
||||
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _render_element(
|
||||
tag_name: str,
|
||||
attrs_key: tuple[tuple[str, str], ...],
|
||||
children_key: tuple[tuple[str, bool], ...],
|
||||
) -> str:
|
||||
"""Pure, memoized HTML builder behind `Component`.
|
||||
|
||||
Inputs are fully hashable and fully determine the output, so identical
|
||||
elements are rendered once. `attrs_key` is (name, stringified value) pairs
|
||||
(attribute values are always escaped). `children_key` is (child, is_safe)
|
||||
pairs: SafeText children pass through, plain strings are escaped. The
|
||||
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
|
||||
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
|
||||
render with the wrong escaping.
|
||||
"""
|
||||
children_blob = "\n".join(
|
||||
child if is_safe else escape(child) for child, is_safe in children_key
|
||||
)
|
||||
if attrs_key:
|
||||
attributes_blob = " " + " ".join(
|
||||
f'{name}="{escape(value)}"' for name, value in attrs_key
|
||||
)
|
||||
else:
|
||||
attributes_blob = ""
|
||||
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
"""Render an HTML element. Attribute values are always escaped; children are
|
||||
escaped unless they are `SafeText` (so nested components pass through),
|
||||
preventing accidental HTML injection. Rendering is memoized via
|
||||
`_render_element`."""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
attrs_key = tuple((name, str(value)) for name, value in attributes)
|
||||
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
|
||||
return mark_safe(_render_element(tag_name, attrs_key, children_key))
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = (
|
||||
content_hash[:length]
|
||||
if not seed
|
||||
else content_hash[: max(0, length - len(seed))]
|
||||
)
|
||||
return seed + base
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Domain components for games / purchases / sessions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import Component, HTMLTag
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
Div,
|
||||
Icon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Span,
|
||||
)
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
|
||||
def GameLink(
|
||||
game_id: int,
|
||||
name: str = "",
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
||||
from django.urls import reverse
|
||||
|
||||
children = children or []
|
||||
display = children if children else [name]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Span(
|
||||
attributes=[("class", "truncate-container")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", link),
|
||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||
],
|
||||
children=display if isinstance(display, list) else [display],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_STATUS_COLORS = {
|
||||
"u": "bg-gray-500",
|
||||
"p": "bg-orange-400",
|
||||
"f": "bg-green-500",
|
||||
"a": "bg-red-500",
|
||||
"r": "bg-purple-500",
|
||||
}
|
||||
|
||||
|
||||
def GameStatus(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
status: str = "u",
|
||||
display: str = "",
|
||||
class_: str = "",
|
||||
) -> SafeText:
|
||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||
children = children or []
|
||||
outer_class = (
|
||||
f"{'flex' if display == 'flex' else 'inline-flex'} "
|
||||
"gap-2 items-center align-middle"
|
||||
)
|
||||
if class_:
|
||||
outer_class += f" {class_}"
|
||||
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
|
||||
|
||||
dot = Span(
|
||||
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
|
||||
children=["\xa0"],
|
||||
)
|
||||
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + (children if isinstance(children, list) else [children]),
|
||||
)
|
||||
|
||||
|
||||
def PriceConverted(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Span(
|
||||
attributes=[
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("class", "decoration-dotted underline"),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = (
|
||||
(purchase.platform.icon if purchase.platform else "unspecified")
|
||||
if game_count == 1
|
||||
else "unspecified"
|
||||
)
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
|
||||
"""Alpine.js dropdown to change a game's status."""
|
||||
options_html = "\n".join(
|
||||
f"<template x-if=\"status == '{value}'\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||
f"</template>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
list_items = "\n".join(
|
||||
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': status === '{value}'}}\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
|
||||
f"</a></li>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
status: '{game.status}',
|
||||
status_display: '{game.get_status_display()}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setStatus(newStatus, newStatusDisplay) {{
|
||||
this.status = newStatus;
|
||||
this.status_display = newStatusDisplay;
|
||||
this.saving = true;
|
||||
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
|
||||
method: 'PATCH',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{csrf_token}'
|
||||
}},
|
||||
body: JSON.stringify({{ status: newStatus }})
|
||||
}})
|
||||
.then(() => {{
|
||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||
}})
|
||||
.catch(() => {{
|
||||
console.error('Failed to update status');
|
||||
}})
|
||||
.finally(() => this.saving = false);
|
||||
}}
|
||||
}}">
|
||||
{_dropdown_button_html(options_html, list_items)}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
|
||||
"""Alpine.js dropdown to change a session's device."""
|
||||
device_id = session.device_id or "null"
|
||||
device_name = (session.device.name if session.device else "Unknown").replace(
|
||||
"'", "\\'"
|
||||
)
|
||||
|
||||
list_items = "\n".join(
|
||||
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
||||
for d in session_devices
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
originalDeviceId: {device_id},
|
||||
originalDeviceName: '{device_name}',
|
||||
deviceId: {device_id},
|
||||
deviceName: '{device_name}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setDevice(newDeviceId, newDeviceName) {{
|
||||
this.deviceId = newDeviceId;
|
||||
this.deviceName = newDeviceName;
|
||||
this.saving = true;
|
||||
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
|
||||
method: 'PATCH',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{csrf_token}'
|
||||
}},
|
||||
body: JSON.stringify({{ device_id: newDeviceId }})
|
||||
}})
|
||||
.then((res) => {{
|
||||
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
||||
}})
|
||||
.catch(() => {{
|
||||
this.deviceName = this.originalDeviceName;
|
||||
this.deviceId = this.originalDeviceId;
|
||||
console.error('Failed to update device');
|
||||
}})
|
||||
.finally(() => this.saving = false);
|
||||
}}
|
||||
}}">
|
||||
{
|
||||
_dropdown_button_html(
|
||||
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
|
||||
)
|
||||
}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _dropdown_button_html(button_content: str, list_items: str) -> str:
|
||||
"""Shared dropdown button + list structure for Alpine.js selectors."""
|
||||
return (
|
||||
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
|
||||
'<button type="button" @click="open = !open" '
|
||||
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
|
||||
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
|
||||
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
|
||||
'dark:focus:text-white align-middle hover:cursor-pointer">'
|
||||
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
|
||||
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
|
||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
|
||||
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
|
||||
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
|
||||
f"{list_items}"
|
||||
"</ul>"
|
||||
"</div>"
|
||||
"</button>"
|
||||
"</div>"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,934 @@
|
||||
"""Generic HTML primitives (no domain knowledge)."""
|
||||
|
||||
from django.middleware.csrf import get_token
|
||||
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 common.icons import get_icon
|
||||
from common.utils import truncate
|
||||
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
||||
|
||||
|
||||
_COLOR_CLASSES = {
|
||||
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
|
||||
"red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white",
|
||||
"gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border",
|
||||
"green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white",
|
||||
}
|
||||
|
||||
|
||||
_SIZE_CLASSES = {
|
||||
"xs": "px-3 py-2 text-xs shadow-xs",
|
||||
"sm": "px-3 py-2 text-sm",
|
||||
"base": "px-5 py-2.5 text-sm",
|
||||
"lg": "px-5 py-3 text-base",
|
||||
"xl": "px-6 py-3.5 text-base",
|
||||
}
|
||||
|
||||
|
||||
def _popover_html(
|
||||
id: str,
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
slot: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate popover HTML using Component(tag_name=...).
|
||||
|
||||
Single source of truth for popover HTML structure.
|
||||
Used by Popover() and the python_popover template tag bridge.
|
||||
"""
|
||||
display_content = wrapped_content if wrapped_content else slot
|
||||
|
||||
span = Span(
|
||||
attributes=[
|
||||
("data-popover-target", id),
|
||||
("class", wrapped_classes),
|
||||
],
|
||||
children=[display_content] if display_content else [],
|
||||
)
|
||||
|
||||
popover_tooltip_class = (
|
||||
"absolute z-10 invisible inline-block text-sm text-white "
|
||||
"transition-opacity duration-300 bg-white border border-purple-200 "
|
||||
"rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 "
|
||||
"dark:bg-purple-800"
|
||||
)
|
||||
|
||||
div = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("data-popover", ""),
|
||||
("id", id),
|
||||
("role", "tooltip"),
|
||||
("class", popover_tooltip_class),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "px-3 py-2")],
|
||||
children=[popover_content],
|
||||
),
|
||||
Component(tag_name="div", attributes=[("data-popper-arrow", "")]),
|
||||
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
|
||||
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
||||
"from Python component -->"
|
||||
),
|
||||
Span(
|
||||
attributes=[("class", "hidden decoration-dotted")],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return mark_safe(span + "\n" + div)
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
id: str = "",
|
||||
) -> str:
|
||||
children = children or []
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
if not id:
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
|
||||
slot = mark_safe("\n".join(children))
|
||||
return _popover_html(
|
||||
id=id,
|
||||
popover_content=popover_content,
|
||||
wrapped_content=wrapped_content,
|
||||
wrapped_classes=wrapped_classes,
|
||||
slot=slot,
|
||||
)
|
||||
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
The truncated text ends in `ellipsis`, and optionally
|
||||
an always-visible `endpart` can be specified.
|
||||
`popover_content` can be specified if:
|
||||
1. It needs to be always displayed regardless if text is truncated.
|
||||
2. It needs to differ from `input_string`.
|
||||
"""
|
||||
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
|
||||
return Popover(
|
||||
wrapped_content=truncated,
|
||||
popover_content=popover_content if popover_content else input_string,
|
||||
)
|
||||
else:
|
||||
if popover_content and popover_if_not_truncated:
|
||||
return Popover(
|
||||
wrapped_content=input_string,
|
||||
popover_content=popover_content if popover_content else "",
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
Accepts one of two mutually-exclusive URL specifications:
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
|
||||
additional_attributes = []
|
||||
if url_name is not None:
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
type: str = "button",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
hx_swap: str = "",
|
||||
title: str = "",
|
||||
onclick: str = "",
|
||||
name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
|
||||
# Separate custom class from other generic attributes
|
||||
custom_class = ""
|
||||
other_attrs: list[HTMLAttribute] = []
|
||||
for attr_name, attr_value in attributes:
|
||||
if attr_name == "class":
|
||||
custom_class = str(attr_value)
|
||||
else:
|
||||
other_attrs.append((attr_name, attr_value))
|
||||
|
||||
# Build class string: custom class first, then base, color, size, icon
|
||||
class_parts: list[str] = []
|
||||
if custom_class:
|
||||
class_parts.append(custom_class)
|
||||
class_parts.append(
|
||||
"hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 "
|
||||
"font-medium mb-2 me-2 rounded-base"
|
||||
)
|
||||
class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"]))
|
||||
class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"]))
|
||||
if icon:
|
||||
class_parts.append("inline-flex text-center items-center gap-2")
|
||||
|
||||
# Build the full attribute list for the button tag
|
||||
button_attrs: list[HTMLAttribute] = [
|
||||
("type", type),
|
||||
("class", " ".join(class_parts)),
|
||||
]
|
||||
if hx_get:
|
||||
button_attrs.append(("hx-get", hx_get))
|
||||
if hx_target:
|
||||
button_attrs.append(("hx-target", hx_target))
|
||||
if hx_swap:
|
||||
button_attrs.append(("hx-swap", hx_swap))
|
||||
if title:
|
||||
button_attrs.append(("title", title))
|
||||
if onclick:
|
||||
button_attrs.append(("onclick", onclick))
|
||||
if name:
|
||||
button_attrs.append(("name", name))
|
||||
button_attrs.extend(other_attrs)
|
||||
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=button_attrs,
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
_GROUP_BUTTON_COLORS = {
|
||||
"gray": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 "
|
||||
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 "
|
||||
"dark:focus:ring-blue-500 dark:focus:text-white"
|
||||
),
|
||||
"red": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 "
|
||||
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
|
||||
"dark:hover:text-white dark:hover:border-red-700 "
|
||||
"dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"
|
||||
),
|
||||
"green": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-green-500 hover:border-green-600 "
|
||||
"hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 "
|
||||
"focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:text-white dark:hover:text-white dark:hover:border-green-700 "
|
||||
"dark:hover:bg-green-600 dark:focus:ring-green-500 "
|
||||
"dark:focus:text-white"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _button_group_button(
|
||||
href: str,
|
||||
slot: str,
|
||||
color: str = "gray",
|
||||
title: str = "",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
||||
|
||||
a_attrs: list[HTMLAttribute] = [("href", href)]
|
||||
if hx_get:
|
||||
a_attrs.append(("hx-get", hx_get))
|
||||
if hx_target:
|
||||
a_attrs.append(("hx-target", hx_target))
|
||||
a_attrs.append(
|
||||
(
|
||||
"class",
|
||||
"[&:first-of-type_button]:rounded-s-lg "
|
||||
"[&:last-of-type_button]:rounded-e-lg",
|
||||
)
|
||||
)
|
||||
|
||||
button = Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("title", title),
|
||||
("class", color_classes + " hover:cursor-pointer"),
|
||||
],
|
||||
children=[slot],
|
||||
)
|
||||
|
||||
return Component(tag_name="a", attributes=a_attrs, children=[button])
|
||||
|
||||
|
||||
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
"""Generate a button group div.
|
||||
|
||||
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
|
||||
Empty dicts (no slot) are silently skipped — matching the template behavior
|
||||
for conditional buttons (e.g., end-session only when session is active).
|
||||
"""
|
||||
buttons = buttons or []
|
||||
children: list[SafeText] = []
|
||||
for btn in buttons:
|
||||
if not btn or not btn.get("slot"):
|
||||
continue
|
||||
children.append(
|
||||
_button_group_button(
|
||||
href=btn.get("href", "#"),
|
||||
slot=btn["slot"],
|
||||
color=btn.get("color", "gray"),
|
||||
title=btn.get("title", ""),
|
||||
hx_get=btn.get("hx_get", ""),
|
||||
hx_target=btn.get("hx_target", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Div(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
|
||||
|
||||
def Span(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="span", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Label(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="label", attributes=attributes, children=children)
|
||||
|
||||
|
||||
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
|
||||
# input.css, written inline so styling stays encapsulated in the component). The
|
||||
# JS that builds pills client-side (search_select.js) MUST emit these exact class
|
||||
# strings byte-for-byte so Tailwind generates them and server/JS pills match.
|
||||
_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
)
|
||||
_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
|
||||
|
||||
def Pill(
|
||||
label: str,
|
||||
*,
|
||||
value: str = "",
|
||||
removable: bool = False,
|
||||
extra_class: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A small label pill, optionally removable (× button).
|
||||
|
||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||
are JS hooks only (no CSS attached). ``value`` (when set) becomes
|
||||
``data-value``; extra ``attributes`` are appended to the outer span.
|
||||
"""
|
||||
attributes = attributes or []
|
||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
||||
if value != "":
|
||||
pill_attrs.append(("data-value", str(value)))
|
||||
pill_attrs.extend(attributes)
|
||||
|
||||
children: list[HTMLTag] = [label]
|
||||
if removable:
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
("class", _PILL_REMOVE_CLASS),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["×"],
|
||||
)
|
||||
)
|
||||
|
||||
return Component(tag_name="span", attributes=pill_attrs, children=children)
|
||||
|
||||
|
||||
def CsrfInput(request) -> SafeText:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||
return mark_safe(
|
||||
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||
)
|
||||
|
||||
|
||||
def ModuleScript(filename: str) -> SafeText:
|
||||
"""A `<script type="module">` tag pointing at a static JS file."""
|
||||
return mark_safe(
|
||||
f'<script type="module" src="{static("js/" + filename)}"></script>'
|
||||
)
|
||||
|
||||
|
||||
def ExternalScript(url: str) -> SafeText:
|
||||
"""A plain `<script src=...>` tag for an external/CDN script."""
|
||||
return mark_safe(f'<script src="{url}"></script>')
|
||||
|
||||
|
||||
def YearPicker(
|
||||
year: int | None = None,
|
||||
available_years: tuple[int, ...] = (),
|
||||
url_template: str = "",
|
||||
) -> SafeText:
|
||||
"""A Flowbite-datepicker year picker.
|
||||
|
||||
`year` is the selected year, or ``None`` for the all-time view (the empty
|
||||
state). `available_years` are the years to enable in the popup grid.
|
||||
`url_template` is a navigation URL containing the literal ``__year__``
|
||||
placeholder, substituted with the chosen year in JS (keeps this component
|
||||
decoupled from the project's URL names).
|
||||
|
||||
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
|
||||
via ``render_page(scripts=...)``.
|
||||
"""
|
||||
label = str(year) if year is not None else "Choose a year"
|
||||
selected = str(year) if year is not None else ""
|
||||
classes = (
|
||||
"bg-brand text-white border-transparent hover:bg-brand-strong"
|
||||
if year is not None
|
||||
else "bg-neutral-secondary-medium text-heading border border-default-medium "
|
||||
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
|
||||
)
|
||||
years_csv = ",".join(str(y) for y in available_years)
|
||||
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||
@keydown.escape.window="pickerOpen = false">
|
||||
<button type="button"
|
||||
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
|
||||
class="inline-flex items-center rounded-base px-4 py-2 text-sm font-medium {classes}">
|
||||
{label}
|
||||
<svg class="w-4 h-4 ms-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="text" x-ref="pickerInput" id="year-picker-input"
|
||||
class="absolute opacity-0 pointer-events-none"
|
||||
style="width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;"
|
||||
data-available-years="{years_csv}"
|
||||
data-selected-year="{selected}"
|
||||
data-url-template="{url_template}">
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
const pickerEl = document.getElementById('year-picker-input');
|
||||
if (!pickerEl || pickerEl._pickerInstance) return;
|
||||
|
||||
const selectedYear = pickerEl.dataset.selectedYear;
|
||||
const urlTemplate = pickerEl.dataset.urlTemplate;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const availableYears = new Set(pickerEl.dataset.availableYears
|
||||
.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n)));
|
||||
|
||||
const picker = new Datepicker(pickerEl, {{
|
||||
pickLevel: 2,
|
||||
format: 'yyyy',
|
||||
minDate: new Date(1999, 0, 1),
|
||||
maxDate: new Date(currentYear, 11, 31),
|
||||
autohide: false,
|
||||
orientation: 'bottom end',
|
||||
showOnClick: false,
|
||||
showOnFocus: false,
|
||||
beforeShowYear: (date) => ({{ enabled: availableYears.has(date.getFullYear()) }})
|
||||
}});
|
||||
pickerEl._pickerInstance = picker;
|
||||
|
||||
picker.element.addEventListener('changeDate', (e) => {{
|
||||
const year = e.detail.date?.getFullYear();
|
||||
if (year && urlTemplate) {{
|
||||
window.location.href = urlTemplate.replace('__year__', year);
|
||||
}}
|
||||
}});
|
||||
|
||||
if (selectedYear) {{
|
||||
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
|
||||
picker.update();
|
||||
}}
|
||||
}});
|
||||
</script>""")
|
||||
|
||||
|
||||
def AddForm(
|
||||
form,
|
||||
*,
|
||||
request,
|
||||
fields: SafeText | str | None = None,
|
||||
additional_row: SafeText | str = "",
|
||||
submit_class: str = "mt-3",
|
||||
) -> SafeText:
|
||||
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
||||
|
||||
`fields` overrides the default ``form.as_div()`` field markup (used by the
|
||||
session form, which lays out its fields manually). `additional_row` holds
|
||||
extra submit buttons rendered below the main Submit button. `submit_class`
|
||||
is applied to the main Submit button (the session form passes "" to match
|
||||
its original markup).
|
||||
"""
|
||||
field_markup = fields if fields is not None else mark_safe(form.as_div())
|
||||
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||
|
||||
inner_form = Component(
|
||||
tag_name="form",
|
||||
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
field_markup,
|
||||
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
||||
Div(
|
||||
[("class", "submit-button-container")],
|
||||
[additional_row] if additional_row else [],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return Div(
|
||||
[("id", "add-form"), ("class", "max-width-container")],
|
||||
[
|
||||
Div(
|
||||
[("id", "add-form"), ("class", "form-container max-w-xl mx-auto")],
|
||||
[inner_form],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def SearchField(
|
||||
search_string: str = "",
|
||||
id: str = "search_string",
|
||||
placeholder: str = "Search",
|
||||
) -> SafeText:
|
||||
"""Generate a search form with icon, input field, and submit button."""
|
||||
return Component(
|
||||
tag_name="form",
|
||||
attributes=[("class", "max-w-md")],
|
||||
children=[
|
||||
Label(
|
||||
attributes=[
|
||||
("for", "search"),
|
||||
("class", "block mb-2.5 text-sm font-medium text-heading sr-only"),
|
||||
],
|
||||
children=["Search"],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "relative")],
|
||||
children=[
|
||||
mark_safe(
|
||||
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
|
||||
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
|
||||
'fill="none" viewBox="0 0 24 24">'
|
||||
'<path stroke="currentColor" stroke-linecap="round" stroke-width="2" '
|
||||
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
|
||||
"</svg></div>"
|
||||
),
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "search"),
|
||||
("id", id),
|
||||
("name", id),
|
||||
("value", search_string),
|
||||
(
|
||||
"class",
|
||||
"block w-full p-3 ps-9 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",
|
||||
),
|
||||
("placeholder", placeholder),
|
||||
("required", ""),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
"class",
|
||||
"absolute end-1.5 bottom-1.5 text-white bg-brand "
|
||||
"hover:bg-brand-strong box-border border border-transparent "
|
||||
"focus:ring-4 focus:ring-brand-medium shadow-xs font-medium "
|
||||
"leading-5 rounded text-xs px-3 py-1.5 focus:outline-none "
|
||||
"cursor-pointer",
|
||||
),
|
||||
],
|
||||
children=["Search"],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def H1(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
badge: str = "",
|
||||
) -> SafeText:
|
||||
"""Heading with optional badge count."""
|
||||
children = children or []
|
||||
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
|
||||
badge_html = ""
|
||||
|
||||
if badge:
|
||||
heading_class = "flex items-center " + heading_class
|
||||
badge_html = Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"bg-blue-100 text-blue-800 text-2xl font-semibold me-2 "
|
||||
"px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2",
|
||||
),
|
||||
],
|
||||
children=[badge],
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
attributes=[("class", heading_class)],
|
||||
children=(children if isinstance(children, list) else [children])
|
||||
+ ([badge_html] if badge_html else []),
|
||||
)
|
||||
|
||||
|
||||
def Modal(
|
||||
modal_id: str,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||
children = children or []
|
||||
outer = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("id", modal_id),
|
||||
(
|
||||
"class",
|
||||
"fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto "
|
||||
"h-full w-full flex items-center justify-center",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"relative mx-auto p-5 border-accent border w-full max-w-md "
|
||||
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=(children if isinstance(children, list) else [children]),
|
||||
),
|
||||
],
|
||||
)
|
||||
return mark_safe(str(outer))
|
||||
|
||||
|
||||
def TableTd(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Styled table cell."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="td",
|
||||
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
"""Generate a <tr> from a row data dict or list.
|
||||
|
||||
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
|
||||
- first cell is <th>, rest <td>.
|
||||
List form: [...] — all cells are <td>.
|
||||
"""
|
||||
if data is None:
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
row_id = data.get("row_id", "")
|
||||
cells = data.get("cell_data", [])
|
||||
else:
|
||||
row_id = ""
|
||||
cells = data
|
||||
|
||||
tr_class = (
|
||||
"odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 "
|
||||
"dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 "
|
||||
"dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 "
|
||||
"[&_a]:decoration-2 [&_td:last-child]:text-right"
|
||||
)
|
||||
tr_attrs: list[HTMLAttribute] = [("class", tr_class)]
|
||||
if row_id:
|
||||
tr_attrs.append(("id", row_id))
|
||||
if isinstance(data, dict):
|
||||
if data.get("hx_trigger"):
|
||||
tr_attrs.append(("hx-trigger", data["hx_trigger"]))
|
||||
if data.get("hx_get"):
|
||||
tr_attrs.append(("hx-get", data["hx_get"]))
|
||||
if data.get("hx_select"):
|
||||
tr_attrs.append(("hx-select", data["hx_select"]))
|
||||
if data.get("hx_swap"):
|
||||
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
||||
|
||||
cell_elements: list[SafeText] = []
|
||||
for i, cell in enumerate(cells):
|
||||
if i == 0:
|
||||
cell_elements.append(
|
||||
Component(
|
||||
tag_name="th",
|
||||
attributes=[
|
||||
("scope", "row"),
|
||||
(
|
||||
"class",
|
||||
"px-6 py-4 font-medium text-gray-900 "
|
||||
"whitespace-nowrap dark:text-white",
|
||||
),
|
||||
],
|
||||
children=[cell],
|
||||
)
|
||||
)
|
||||
else:
|
||||
cell_elements.append(TableTd(children=[cell]))
|
||||
|
||||
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
return mark_safe(get_icon(name))
|
||||
|
||||
|
||||
def TableHeader(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Table caption."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="caption",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"p-2 text-lg font-semibold rtl:text-left text-right "
|
||||
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def _page_url(request, page) -> str:
|
||||
"""Current querystring with `page` replaced (mirrors {% param_replace %})."""
|
||||
if request is None:
|
||||
return f"?page={page}"
|
||||
params = request.GET.copy()
|
||||
params["page"] = page
|
||||
return "?" + params.urlencode()
|
||||
|
||||
|
||||
def _pagination_nav(page_obj, elided_page_range, request) -> str:
|
||||
pages_html = ""
|
||||
for page in elided_page_range:
|
||||
if page != page_obj.number:
|
||||
pages_html += (
|
||||
f'<li><a href="{_page_url(request, page)}" '
|
||||
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
|
||||
"bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||
f'dark:hover:text-white">{conditional_escape(page)}</a></li>'
|
||||
)
|
||||
else:
|
||||
pages_html += (
|
||||
'<li><a aria-current="page" '
|
||||
'class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight '
|
||||
"text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 "
|
||||
f'dark:text-gray-200">{conditional_escape(page)}</a></li>'
|
||||
)
|
||||
|
||||
if page_obj.has_previous():
|
||||
prev_html = (
|
||||
f'<a href="{_page_url(request, page_obj.previous_page_number())}" '
|
||||
'class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 '
|
||||
"bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||
'dark:hover:text-white">Previous</a>'
|
||||
)
|
||||
else:
|
||||
prev_html = (
|
||||
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
|
||||
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg "
|
||||
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>'
|
||||
)
|
||||
|
||||
if page_obj.has_next():
|
||||
next_html = (
|
||||
f'<a href="{_page_url(request, page_obj.next_page_number())}" '
|
||||
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
|
||||
"bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||
'dark:hover:text-white">Next</a>'
|
||||
)
|
||||
else:
|
||||
next_html = (
|
||||
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
|
||||
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg "
|
||||
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 '
|
||||
'dark:bg-gray-900 sm:rounded-b-lg" aria-label="Table navigation">'
|
||||
'<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 '
|
||||
'md:mb-0 block w-full md:inline md:w-auto">'
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.start_index()}</span>—'
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.end_index()}</span> of '
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.paginator.count}</span></span>'
|
||||
'<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8"><li>'
|
||||
f"{prev_html}{pages_html}{next_html}"
|
||||
"</li></ul></nav>"
|
||||
)
|
||||
|
||||
|
||||
def SimpleTable(
|
||||
columns: list[str] | None = None,
|
||||
rows: list | None = None,
|
||||
header_action: SafeText | str | None = None,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||
columns = columns or []
|
||||
rows = rows or []
|
||||
|
||||
header_html = ""
|
||||
if header_action:
|
||||
header_html = str(TableHeader(children=[header_action]))
|
||||
|
||||
columns_html = "".join(
|
||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||
for col in columns
|
||||
)
|
||||
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
||||
|
||||
pagination_html = ""
|
||||
if page_obj and elided_page_range:
|
||||
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||
|
||||
return mark_safe(
|
||||
'<div class="shadow-md" hx-boost="false">'
|
||||
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
|
||||
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
|
||||
f"{header_html}"
|
||||
'<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 '
|
||||
'dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"<tr>{columns_html}</tr></thead>"
|
||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"{rows_html}</tbody></table></div>"
|
||||
f"{pagination_html}</div>"
|
||||
)
|
||||
|
||||
|
||||
def paginated_table_content(
|
||||
data: dict,
|
||||
*,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||
|
||||
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||
``header_action`` (the same shape every list view already builds).
|
||||
"""
|
||||
return Div(
|
||||
[
|
||||
(
|
||||
"class",
|
||||
"2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) "
|
||||
"md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center",
|
||||
)
|
||||
],
|
||||
[
|
||||
SimpleTable(
|
||||
columns=data["columns"],
|
||||
rows=data["rows"],
|
||||
header_action=data["header_action"],
|
||||
page_obj=page_obj,
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Search field + dropdown select component (pure Python, domain-agnostic).
|
||||
|
||||
Pairs a search box with a dropdown of options. Supports single/multi select;
|
||||
in multi-select, chosen items render as removable ``Pill``s, each backed by a
|
||||
hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
|
||||
|
||||
This module imports only from ``common.components`` — it has no Django-forms or
|
||||
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
|
||||
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TypedDict
|
||||
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components.core import Component, HTMLAttribute
|
||||
from common.components.primitives import Pill
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
value: str | int
|
||||
label: str
|
||||
data: dict[str, str] # becomes data-* attrs on the row / pill
|
||||
|
||||
|
||||
# removed border and border-default-medium, see later if it's needed
|
||||
_CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium"
|
||||
# The pills and the search box share one flex-wrap row so the widget reads as a
|
||||
# single field; the pills wrapper uses `contents` so its pills/hidden inputs
|
||||
# flow as direct participants of that row, inline with the search input.
|
||||
_FIELD_CLASS = "flex flex-wrap items-center gap-1 p-2"
|
||||
_PILLS_CLASS = "contents"
|
||||
_SEARCH_CLASS = (
|
||||
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
|
||||
"focus:ring-0 focus:outline-hidden placeholder:text-body"
|
||||
)
|
||||
_OPTIONS_CLASS = (
|
||||
"absolute z-10 left-0 right-0 mt-1 overflow-y-auto border border-default-medium "
|
||||
"rounded-base bg-neutral-secondary-medium shadow-lg"
|
||||
)
|
||||
_OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15"
|
||||
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
|
||||
|
||||
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
|
||||
# used to derive the panel's max-height from items_visible.
|
||||
_ROW_HEIGHT_REM = 2.25
|
||||
|
||||
|
||||
def _normalize_option(option) -> SearchSelectOption:
|
||||
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
|
||||
if isinstance(option, dict):
|
||||
return {
|
||||
"value": option["value"],
|
||||
"label": option["label"],
|
||||
"data": option.get("data") or {},
|
||||
}
|
||||
value, label = option
|
||||
return {"value": value, "label": label, "data": {}}
|
||||
|
||||
|
||||
def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
|
||||
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
||||
|
||||
|
||||
def _hidden_input(name: str, value) -> SafeText:
|
||||
return Component(
|
||||
tag_name="input",
|
||||
attributes=[("type", "hidden"), ("name", name), ("value", str(value))],
|
||||
)
|
||||
|
||||
|
||||
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("data-ss-option", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("class", _OPTION_ROW_CLASS),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[option["label"]],
|
||||
)
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
*,
|
||||
name: str,
|
||||
selected: list[SearchSelectOption] | None = None,
|
||||
options: list[SearchSelectOption] | None = None,
|
||||
search_url: str = "",
|
||||
multi_select: bool = False,
|
||||
always_visible: bool = False,
|
||||
items_visible: int = 5,
|
||||
items_scroll: int = 10,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
sync_url: bool = False,
|
||||
) -> SafeText:
|
||||
"""Render the search-select widget. See module docstring for the contract."""
|
||||
selected = [_normalize_option(o) for o in (selected or [])]
|
||||
options = [_normalize_option(o) for o in (options or [])]
|
||||
|
||||
# ── Pills + their hidden inputs (the submitted channel) ──
|
||||
# Multi-select renders a removable Pill per value; single-select renders no
|
||||
# pill — the committed label shows inside the search box instead, with a
|
||||
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||
# `[data-ss-pills]` so the JS reads/writes values uniformly.
|
||||
pills_children: list[SafeText] = []
|
||||
search_value = ""
|
||||
if multi_select:
|
||||
for option in selected:
|
||||
pills_children.append(
|
||||
Pill(
|
||||
option["label"],
|
||||
value=str(option["value"]),
|
||||
removable=True,
|
||||
attributes=_data_attributes(option["data"]),
|
||||
)
|
||||
)
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
elif selected:
|
||||
option = selected[0]
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
search_value = option["label"]
|
||||
|
||||
pills = Component(
|
||||
tag_name="div",
|
||||
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attrs: list[HTMLAttribute] = [
|
||||
("data-ss-search", ""),
|
||||
("type", "text"),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
if search_value:
|
||||
search_attrs.append(("value", search_value))
|
||||
search = Component(tag_name="input", attributes=search_attrs)
|
||||
|
||||
# ── Field row: pills + search box combined into one visual field ──
|
||||
field = Component(
|
||||
tag_name="div",
|
||||
attributes=[("data-ss-field", ""), ("class", _FIELD_CLASS)],
|
||||
children=[pills, search],
|
||||
)
|
||||
|
||||
# ── Options panel (pre-rendered only when there is no search_url) ──
|
||||
option_rows = [_option_row(o) for o in options] if not search_url else []
|
||||
no_results = Component(
|
||||
tag_name="div",
|
||||
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)],
|
||||
children=["No results"],
|
||||
)
|
||||
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
||||
options_panel = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("data-ss-options", ""),
|
||||
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
||||
("class", options_class),
|
||||
],
|
||||
children=[*option_rows, no_results],
|
||||
)
|
||||
|
||||
container_attrs: list[HTMLAttribute] = [
|
||||
("data-search-select", ""),
|
||||
("data-name", name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true" if multi_select else "false"),
|
||||
("data-always-visible", "true" if always_visible else "false"),
|
||||
("data-items-visible", str(items_visible)),
|
||||
("data-items-scroll", str(items_scroll)),
|
||||
("data-sync-url", "true" if sync_url else "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if id:
|
||||
container_attrs.append(("id", id))
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=container_attrs,
|
||||
children=[field, options_panel],
|
||||
)
|
||||
|
||||
|
||||
def searchselect_selected(
|
||||
values: list,
|
||||
resolver: Callable[[list], Iterable[SearchSelectOption]],
|
||||
) -> list[SearchSelectOption]:
|
||||
"""Resolve ``values`` into ``SearchSelectOption``s via ``resolver``.
|
||||
|
||||
``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query)
|
||||
— never iterating all choices, so it stays cheap.
|
||||
"""
|
||||
if not values:
|
||||
return []
|
||||
return [_normalize_option(o) for o in resolver(values)]
|
||||
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
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,25 @@
|
||||
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", ""))
|
||||
@@ -20,8 +20,8 @@ def import_data(data: DataList):
|
||||
# try exact match first
|
||||
try:
|
||||
game_id = Game.objects.get(name__iexact=name)
|
||||
except:
|
||||
pass
|
||||
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
|
||||
game_id = None
|
||||
matching_names[name] = game_id
|
||||
print(f"Exact matched {len(matching_names)} games.")
|
||||
|
||||
|
||||
+242
-66
@@ -1,103 +1,279 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
@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;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
|
||||
/*
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Serif";
|
||||
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
@utility min-w-20char {
|
||||
min-width: 20ch;
|
||||
}
|
||||
|
||||
form label {
|
||||
@apply dark:text-slate-400;
|
||||
@utility max-w-20char {
|
||||
max-width: 20ch;
|
||||
}
|
||||
|
||||
.responsive-table {
|
||||
@apply dark:text-white mx-auto;
|
||||
@utility min-w-30char {
|
||||
min-width: 30ch;
|
||||
}
|
||||
|
||||
.responsive-table tr:nth-child(even) {
|
||||
@apply bg-slate-800
|
||||
@utility max-w-30char {
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.responsive-table tbody tr:nth-child(odd) {
|
||||
@apply bg-slate-900
|
||||
@utility max-w-35char {
|
||||
max-width: 35ch;
|
||||
}
|
||||
|
||||
.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;
|
||||
@utility max-w-40char {
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.max-w-20char {
|
||||
max-width: 20ch;
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
.max-w-35char {
|
||||
max-width: 40ch;
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
.max-w-40char {
|
||||
max-width: 40ch;
|
||||
|
||||
@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,
|
||||
select,
|
||||
textarea {
|
||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||
form input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
|
||||
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 150px;
|
||||
}
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
||||
}
|
||||
|
||||
#button-container button {
|
||||
@apply mx-1;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply text-right;
|
||||
}
|
||||
|
||||
th label {
|
||||
@apply mr-4;
|
||||
}
|
||||
|
||||
.basic-button-container {
|
||||
@apply flex space-x-2 justify-center;
|
||||
}
|
||||
|
||||
.basic-button {
|
||||
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/* SelectableFilter widget styling */
|
||||
.sf-container {
|
||||
@apply border border-default-medium rounded-base bg-neutral-secondary-medium;
|
||||
}
|
||||
.sf-selected {
|
||||
@apply flex flex-wrap gap-1 p-2 min-h-[2rem];
|
||||
}
|
||||
.sf-tag {
|
||||
@apply inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded bg-brand/15 text-heading;
|
||||
}
|
||||
.sf-tag.sf-excluded {
|
||||
@apply bg-red-500/15 text-red-600 line-through decoration-red-400;
|
||||
}
|
||||
.sf-remove {
|
||||
@apply ml-1 text-body hover:text-heading font-bold cursor-pointer;
|
||||
}
|
||||
.sf-modifier-tag {
|
||||
@apply inline-flex items-center px-2 py-0.5 text-sm rounded bg-amber-500/15 text-amber-600 cursor-pointer;
|
||||
}
|
||||
.sf-search {
|
||||
@apply block w-full border-0 border-t border-default-medium bg-transparent text-sm text-heading p-2;
|
||||
&:focus {
|
||||
@apply ring-0 outline-hidden;
|
||||
}
|
||||
}
|
||||
.sf-options {
|
||||
@apply max-h-40 overflow-y-auto p-1 text-body;
|
||||
}
|
||||
.sf-option {
|
||||
@apply flex items-center justify-between px-2 py-1 rounded text-sm hover:bg-neutral-secondary-strong cursor-pointer;
|
||||
}
|
||||
.sf-option-label {
|
||||
@apply truncate;
|
||||
}
|
||||
.sf-option-buttons {
|
||||
@apply flex gap-1 ml-2 shrink-0;
|
||||
}
|
||||
.sf-btn-include,
|
||||
.sf-btn-exclude {
|
||||
@apply w-5 h-5 flex items-center justify-center text-xs font-bold rounded border border-default-medium hover:bg-brand hover:text-white hover:border-brand;
|
||||
}
|
||||
.sf-modifier-option {
|
||||
@apply px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+99
-9
@@ -1,16 +1,19 @@
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from common.utils import generate_split_ranges
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
dateformat: str = "%d/%m/%Y"
|
||||
datetimeformat: str = "%d/%m/%Y %H:%M"
|
||||
timeformat: str = "%H:%M"
|
||||
durationformat: str = "%2.1H hours"
|
||||
durationformat_manual: str = "%H hours"
|
||||
|
||||
|
||||
def _safe_timedelta(duration: timedelta | int | None):
|
||||
if duration == None:
|
||||
if duration is None:
|
||||
return timedelta(0)
|
||||
elif isinstance(duration, int):
|
||||
return timedelta(seconds=duration)
|
||||
@@ -19,7 +22,7 @@ def _safe_timedelta(duration: timedelta | int | None):
|
||||
|
||||
|
||||
def format_duration(
|
||||
duration: timedelta | int | None, format_string: str = "%H hours"
|
||||
duration: timedelta | int | float | None, format_string: str = "%H hours"
|
||||
) -> str:
|
||||
"""
|
||||
Format timedelta into the specified format_string.
|
||||
@@ -44,7 +47,7 @@ def format_duration(
|
||||
# timestamps where end is before start
|
||||
if seconds_total < 0:
|
||||
seconds_total = 0
|
||||
days = hours = minutes = seconds = 0
|
||||
days = hours = hours_float = minutes = seconds = 0
|
||||
remainder = seconds = seconds_total
|
||||
if "%d" in format_string:
|
||||
days, remainder = divmod(seconds_total, day_seconds)
|
||||
@@ -55,7 +58,7 @@ def format_duration(
|
||||
minutes, seconds = divmod(remainder, minute_seconds)
|
||||
literals = {
|
||||
"d": str(days),
|
||||
"H": str(hours),
|
||||
"H": str(hours) if "m" not in format_string else str(hours_float),
|
||||
"m": str(minutes),
|
||||
"s": str(seconds),
|
||||
"r": str(seconds_total),
|
||||
@@ -77,3 +80,90 @@ def format_duration(
|
||||
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)
|
||||
|
||||
+190
@@ -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
|
||||
+12
-19
@@ -1,28 +1,21 @@
|
||||
---
|
||||
services:
|
||||
backend:
|
||||
image: registry.kucharczyk.xyz/timetracker
|
||||
timetracker:
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- TZ=Europe/Prague
|
||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||
user: "1000"
|
||||
- TZ=${TZ:-Europe/Prague}
|
||||
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-100}
|
||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||
ports:
|
||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- "static-files:/home/timetracker/app/static"
|
||||
- "./data:/home/timetracker/app/data"
|
||||
- "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: caddy
|
||||
volumes:
|
||||
- "static-files:/usr/share/caddy"
|
||||
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
static-files:
|
||||
|
||||
|
||||
@@ -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,398 @@
|
||||
# Form Overhaul Plan
|
||||
|
||||
> Last updated: 2026-05-12
|
||||
> Status: Decided — awaiting implementation
|
||||
>
|
||||
> **Decisions made:**
|
||||
> - All forms (simple and complex) get section headers for consistency
|
||||
> - Two-column layout uses **flexbox** (auto-reflow on different screen sizes)
|
||||
> - `cotton/layouts/add.html` enhanced with **Option A**: `c-section` component slots
|
||||
> - `add_purchase.html` dual-submit **simplified** — remove `<tr><td>`, use same `c-button` pattern as `add_game.html`
|
||||
> - GameStatusChange delete confirmation **converted to modal** (via HTMX trigger)
|
||||
|
||||
## Goal
|
||||
|
||||
Modernize all forms and form-like elements to align with Flowbite design, improve visual consistency, and adopt responsive multi-column layouts for complex forms.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Form Pages (add/edit)
|
||||
|
||||
All use `cotton/layouts/add.html` — single column, `max-w-xl`, `form.as_div`:
|
||||
|
||||
| Page | Form | Fields | Complexity |
|
||||
|---|---|---|---|
|
||||
| Game | `GameForm` | 7 fields: name, sort_name, platform, year, year_orig, status, mastered, wikidata | Medium |
|
||||
| Purchase | `PurchaseForm` | 11 fields: games, platform, dates, price, currency, type, ownership, related, infinite, name | High |
|
||||
| Session | `SessionForm` | 8 fields: game, timestamps, duration, emulated, device, note, checkbox (custom rendering) | High |
|
||||
| Platform | `PlatformForm` | 3 fields: name, icon, group | Low |
|
||||
| Device | `DeviceForm` | 2 fields: name, type | Low |
|
||||
| PlayEvent | `PlayEventForm` | 5 fields: game, dates, note, checkbox | Low |
|
||||
| GameStatusChange | `GameStatusChangeForm` | 4 fields | Low |
|
||||
|
||||
### Other Form-Like Elements
|
||||
|
||||
| Element | Template | Notes |
|
||||
|---|---|---|
|
||||
| Login | `registration/login.html` | Flowbite card, already good |
|
||||
| Search | `cotton/search_field.html` | Reusable, already good |
|
||||
| Delete Game | `partials/delete_game_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Delete PlayEvent | `gamestatuschange_confirm_delete.html` | Full-page form, no modal |
|
||||
| Refund Purchase | `partials/refund_purchase_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Stats Year Select | `stats.html` | Manual `<select>`, no Flowbite styling |
|
||||
| Status Selector | `partials/gamestatus_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
| Device Selector | `partials/sessiondevice_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
|
||||
---
|
||||
|
||||
## Issues to Fix
|
||||
|
||||
### P0: Broken/Inconsistent
|
||||
|
||||
1. ~~**`modal.html` has a missing `<form>` tag** (line 13: `</form>` with no opening `<form>`)** — *Resolved: rewritten as proper component with form wrapping support, body + footer slots, reusable `close_button` component. Ready for standardizing all inline modals later.*
|
||||
2. **Delete confirmations are inconsistent** — three different patterns (inline modal, full-page form, inline modal)
|
||||
3. **`.errorlist` CSS** has fixed `width: 300px` — too narrow, breaks on mobile. *No scoping needed: Django auto-applies `.errorlist` to form error output only, never used explicitly in templates.*
|
||||
4. **`add_purchase.html` has `<tr><td>`** in a `c-slot` that renders inside a `<div>` — semantic mismatch. **Decision: simplify dual-submit** to match `add_game.html` pattern (use `<c-button>` only).
|
||||
5. **`#button-container` and `.basic-button` in `input.css`** — legacy patterns, unused or dead code
|
||||
|
||||
### P1: Layout & UX
|
||||
|
||||
6. **All add/edit forms are single-column** — PurchaseForm (11 fields) and GameForm (7 fields) would benefit from multi-column
|
||||
7. **No field grouping** — related fields listed flat without visual hierarchy
|
||||
8. **Stats year `<select>`** has no Flowbite styling
|
||||
9. **Search field** is not wrapped in `<form method="get">` — no native clear-on-Enter behavior
|
||||
|
||||
### P2: Styling Consistency
|
||||
|
||||
10. **Status/device selectors** use old Tailwind v3 patterns (`rounded-sm`, `shadow-2xs`, `border-gray-200` without explicit color)
|
||||
11. **`navbar.html` buttons** use `rounded-sm` instead of `rounded-base`
|
||||
12. **`simple_table.html` pagination buttons** use `rounded-s-lg`/`rounded-e-lg` — could be simplified
|
||||
|
||||
---
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Two-Column Layout for Complex Forms (Flexbox)
|
||||
|
||||
**Scope**: `GameForm`, `PurchaseForm`, `PlayEventForm`, `SessionForm`
|
||||
|
||||
Use **flexbox** with wrap behavior so fields auto-reflow on different screen sizes. No fixed column count — fields sit side-by-side on `md:`+ and wrap naturally on smaller screens.
|
||||
|
||||
#### GameForm Layout
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Game Details │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Name │ Platform │ │
|
||||
│ │ Sort Name │ Year │ │
|
||||
│ │ Original Year │ Wikidata │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ Status │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Status │ Mastered │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ [Submit] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### PurchaseForm Layout (simplified)
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Purchase Details │
|
||||
│ ┌──────────────────────┬───────────────┐ │
|
||||
│ │ Games (multi-select) │ Platform │ │
|
||||
│ │ Type │ Ownership │ │
|
||||
│ │ Name │ Related Purch │ │
|
||||
│ └──────────────────────┴───────────────┘ │
|
||||
│ Dates │ Price │
|
||||
│ ┌───────────────┬──────┴───────────────┐ │
|
||||
│ │ Date Purch │ Price Curr │ │
|
||||
│ │ Date Refund │ Infinite [ ] │ │
|
||||
│ └───────────────┴──────────────────────┘ │
|
||||
│ [Submit] [Submit + Session] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation**: `c-section` component accepts `columns="2"` (or `"3"`) which applies `flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]` on md+ screens. Each field wraps in a `<div>` inside the section slot.
|
||||
|
||||
**Decision**: Dual-submit in `add_purchase.html` simplified — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`.
|
||||
|
||||
### 2. Field Grouping with Card Sections
|
||||
|
||||
**Decision**: ALL forms get section headers for consistency (not just complex forms).
|
||||
|
||||
Group related fields with section headings and subtle borders/backgrounds:
|
||||
|
||||
```html
|
||||
<c-section title="Game Details" columns="2">
|
||||
{{ form.name }}
|
||||
{{ form.platform }}
|
||||
{{ form.sort_name }}
|
||||
{{ form.year_released }}
|
||||
</c-section>
|
||||
```
|
||||
|
||||
Each section renders as:
|
||||
```html
|
||||
<fieldset class="form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0">
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">Section Title</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- fields in <div> wrappers, each taking calc(50% - 0.5rem) on md+ -->
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
Each section gets:
|
||||
- Subtle background (`bg-neutral-primary-soft/30`)
|
||||
- Top border with spacing (`border-t border-default-medium`)
|
||||
- Section heading (`text-sm font-medium text-heading uppercase mb-4`)
|
||||
- Flexbox gap for responsive field reflow
|
||||
|
||||
### 1b. `c-section` Component Specification
|
||||
|
||||
New cotton component for the `cotton/` directory:
|
||||
|
||||
```python
|
||||
# games/templates/cotton/section.py (or inline in components.py)
|
||||
from common.components import Div
|
||||
|
||||
def Section(title: str = "", columns: str = "1", children: str = "") -> SafeText:
|
||||
"""Renders a form field section with optional multi-column flexbox layout.
|
||||
|
||||
Args:
|
||||
title: Section heading (renders as uppercase label)
|
||||
columns: "1" (default), "2", or "3" — target column count on md+ screens
|
||||
children: Field markup (each field wrapped in <div> for flex wrapping)
|
||||
"""
|
||||
col_class = {
|
||||
"1": "flex flex-col",
|
||||
"2": "flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]",
|
||||
"3": "flex flex-wrap gap-4 [&>div]:w-[calc(33.333%-0.67rem)]",
|
||||
}.get(columns, "flex flex-col")
|
||||
|
||||
return Div(
|
||||
cls=f"form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0",
|
||||
children=f"""
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">{title}</h3>
|
||||
<div class="{col_class}">{children}</div>
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
**Template usage:**
|
||||
```django
|
||||
{# add_game.html #}
|
||||
<c-layouts.add title="New Game">
|
||||
<c-section title="Game Details" columns="2">
|
||||
<div>{{ form.name }}</div>
|
||||
<div>{{ form.platform }}</div>
|
||||
<div>{{ form.sort_name }}</div>
|
||||
<div>{{ form.year_released }}</div>
|
||||
<div>{{ form.original_year_released }}</div>
|
||||
<div>{{ form.wikidata }}</div>
|
||||
</c-section>
|
||||
<c-section title="Status" columns="2">
|
||||
<div>{{ form.status }}</div>
|
||||
<div>{{ form.mastered }}</div>
|
||||
</c-section>
|
||||
</c-layouts.add>
|
||||
```
|
||||
|
||||
**`cotton/layouts/add.html` changes:**
|
||||
- Remove hardcoded `{{ form.as_div }}` rendering
|
||||
- Accept optional `sections` variable (list of rendered `c-section` output)
|
||||
- If `sections` provided, render them; otherwise fall back to `{{ form.as_div }}` for simple forms
|
||||
- Keep `additional_row` slot for dual-submit buttons
|
||||
|
||||
### 3. CSS/Style Fixes
|
||||
|
||||
#### `input.css` changes:
|
||||
```css
|
||||
/* Update errorlist */
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-full max-w-xl; /* was w-[300px] */
|
||||
}
|
||||
|
||||
/* Remove: #button-container, .basic-button — unused legacy */
|
||||
|
||||
/* Remove: .flowbite-input — custom class is code smell with Tailwind */
|
||||
/* Remove: flowbite-input @apply block (line 229-234) */
|
||||
|
||||
/* Add Flowbite styling for select in stats */
|
||||
#yearSelect {
|
||||
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand;
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The styling previously provided by `.flowbite-input` must be preserved. The element-level `@apply` rules for `input`, `select`, and `textarea` in `input.css` (lines 209-219) already provide equivalent styling. These rules automatically apply to all form inputs without needing custom classes:
|
||||
- `input:not([type="checkbox"])` — background, border, text, radius, focus ring, padding
|
||||
- `select` — same base styling as inputs
|
||||
- `textarea` — same base styling with adjusted padding
|
||||
|
||||
**Files to clean up:**
|
||||
- `common/input.css`: Remove `.flowbite-input` class entirely (lines 229-234)
|
||||
- `games/forms.py`: Remove `flowbite_input_widget` and `flowbite_password_widget` (lines 22-23)
|
||||
- `games/forms.py`: Remove `widget=` from `LoginForm` fields (lines 28, 32) — login template uses explicit Tailwind classes already
|
||||
|
||||
#### Rewrite `modal.html`:
|
||||
- Remove stray `</form>` tag and restructure as a proper cotton component
|
||||
- New `c-modal` component with: `modal_id`, `title`, `size="xl"`, `backdrop_close` variables
|
||||
- `{{ slot }}` (cotton default slot) for body content — passed as children of `<c-modal>`, no block tags needed
|
||||
- `{{ footer }}` (optional named slot via `<c-slot name="footer">`) for non-form buttons
|
||||
- Reusable `cotton/close_button.html` via `<c-close-button />`
|
||||
- Size mapping via inline `{% if %}`: `{% if size == 'sm' %}max-w-sm{% elif size == 'lg' %}max-w-lg{% else %}max-w-xl{% endif %}`
|
||||
- Horizontal centering: `mx-auto` on inner container (matching old modal pattern)
|
||||
- Click-to-dismiss backdrop with `event.stopPropagation()` on inner container
|
||||
- Flowbite-style styling: `rounded-lg shadow`, `bg-white dark:bg-gray-800`, `sm:p-5`
|
||||
|
||||
### 4. Unify Delete Confirmations (All Modal)
|
||||
|
||||
**Decision**: GameStatusChange delete confirmation converted from full-page to modal. All three use the same modal pattern.
|
||||
|
||||
**Target**: All confirmation modals use the same pattern:
|
||||
|
||||
```html
|
||||
<div class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 ...">
|
||||
<div class="relative mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h2 class="text-xl font-medium text-center">Confirm Action</h2>
|
||||
<p class="text-center mt-4 text-sm text-body">Are you sure...?</p>
|
||||
{% if details %}
|
||||
<ul class="text-center mt-2 text-sm text-body list-disc list-inside">
|
||||
<li>{{ detail }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p class="text-center mt-3 text-sm font-medium text-red-600">This action cannot be undone.</p>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<c-button color="red" class="w-full" type="submit">Delete</c-button>
|
||||
<c-button color="gray" class="w-full">Cancel</c-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- **Delete Game** (`partials/delete_game_confirmation.html`): Update template to match standard pattern
|
||||
- **Delete StatusChange** (`gamestatuschange_confirm_delete.html` → `partials/statuschange_delete_confirmation.html`): Adopt the same 2-view pattern as delete-game.
|
||||
- Add `delete_statuschange_confirmation` view (GET → renders modal partial) + URL before the delete URL
|
||||
- Update `partials/history.html` — add `hx-get="{% url 'games:delete_statuschange_confirmation' change.id %}" hx-target="#global-modal-container"` to the Delete link
|
||||
- Create new `partials/statuschange_delete_confirmation.html` using `<c-modal>`, same structure as `delete_game_confirmation.html` (detail list, red warning text, same button layout, `<c-gamestatus>` badge for old status)
|
||||
- Modify `GameStatusChangeDeleteView` to only handle POST (remove its GET-rendered template)
|
||||
- Delete old `gamestatuschange_confirm_delete.html` after migration
|
||||
- **Refund Purchase** (`partials/refund_purchase_confirmation.html`): Update template to match standard pattern
|
||||
|
||||
### 5. Search Form Enhancement
|
||||
|
||||
Wrap `search_field.html` in proper `<form method="get">`:
|
||||
|
||||
```html
|
||||
<form class="max-w-md mx-auto" method="get" x-data x-on:keydown.escape="this.querySelector('input').value=''; this.submit()">
|
||||
<!-- input + button -->
|
||||
</form>
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Native form submission on Enter
|
||||
- Potential for "clear all" functionality
|
||||
- Proper browser form autofill behavior
|
||||
|
||||
### 6. Status/Device Selector Styling
|
||||
|
||||
Update Alpine.js dropdowns to use consistent button classes:
|
||||
- Replace `rounded-lg` with `rounded-base`
|
||||
- Replace `shadow-2xs` with `shadow-xs`
|
||||
- Standardize border colors with `border-default`
|
||||
- Use `text-heading` / `text-body` for dark mode compatibility
|
||||
|
||||
---
|
||||
|
||||
## Templates That Need Changes
|
||||
|
||||
| Template | Change | Effort |
|
||||
|---|---|---|
|
||||
| `cotton/layouts/add.html` | Add `c-section` component support (title, columns, fields slots) | Medium |
|
||||
| `add_game.html` | Multi-column flexbox layout, section headers | Medium |
|
||||
| `add_purchase.html` | Multi-column flexbox layout, simplify dual-submit, section headers | High |
|
||||
| `add_session.html` | Flexbox layout for timestamps+duration, section headers | Low |
|
||||
| `add_playevent.html` | Flexbox layout, section headers | Low |
|
||||
| `add_platform.html` | Section headers (was flat single-column) | Low |
|
||||
| `add_device.html` | Section headers (was flat single-column) | Low |
|
||||
| `partials/delete_game_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/refund_purchase_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/statuschange_delete_confirmation.html` | New — adopt same 2-view pattern as delete-game (modal, `<c-modal>`, HTMX triggers) | Medium |
|
||||
| `gamestatuschange_confirm_delete.html` | Delete (replaced by new partial) | Trivial |
|
||||
| `cotton/modal.html` | Fix missing `<form>` tag | Low |
|
||||
| `stats.html` | Add Flowbite select styling | Low |
|
||||
| `partials/gamestatus_selector.html` | Update button classes | Low |
|
||||
| `partials/sessiondevice_selector.html` | Update button classes | Low |
|
||||
| `cotton/search_field.html` | Wrap in `<form method="get">` | Low |
|
||||
| `common/input.css` | Remove legacy, fix errorlist, add select styles | Low |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Quick Wins (low risk, no breaking changes)
|
||||
|
||||
1. **CSS fixes** (`input.css`) — fix errorlist width, remove legacy `.basic-button` / `#button-container`, add select styles
|
||||
2. ~~**`modal.html` rewrite**~~ — add missing `<form>` tag, conditional form wrapper ✓ Implemented (uses `{{ slot }}` cotton default slot, no `{% partial %}` tags; `size` defaults to `"xl"` with inline `{% if %}` mapping)
|
||||
3. **Delete confirmation standardization** — 3 templates → all modal, same pattern (including GameStatusChange: full-page → modal)
|
||||
4. **Search field enhancement** — wrap in `<form method="get">`
|
||||
5. **Stats select styling** — add Flowbite select classes
|
||||
6. **Selector styling updates** — gamestatus + sessiondevice selectors, consistent classes
|
||||
|
||||
### Phase 2: `c-section` Component
|
||||
|
||||
7. **Create `c-section` component** — title, columns, fields slots
|
||||
8. **Update `cotton/layouts/add.html`** — support `sections` variable, fallback to `form.as_div`
|
||||
|
||||
### Phase 3: Form Layout Overhaul (largest change)
|
||||
|
||||
9. **`GameForm`** — section headers + 2-col flexbox (`add_game.html`)
|
||||
10. **`PlayEventForm`** — section headers + 2-col flexbox
|
||||
11. **`PurchaseForm`** — section headers + 2/3-col flexbox + simplify dual-submit (`add_purchase.html`)
|
||||
12. **`SessionForm`** — section headers + flexbox for timestamps+duration (custom rendering already exists)
|
||||
13. **Simple forms** — `add_platform.html`, `add_device.html` get section headers (single column)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `make test` after Phase 1 changes to verify nothing broke
|
||||
- `tests/test_paths_return_200.py` — URL-level smoke tests (186 tests). All views must have a `test_*_returns_200` test. Adding new views requires a corresponding test to prevent `TemplateDoesNotExist` regressions.
|
||||
- CSS changes do not require test changes (no test coverage for rendering), but visual verification is recommended
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [x] Simple forms section headers? → **All forms get section headers** for consistency
|
||||
- [x] CSS Grid or Flexbox? → **Flexbox** — auto-reflow on different screen sizes
|
||||
- [x] add.html layout variable? → **Option A** — `c-section` cotton component with `title` and `columns` slots
|
||||
- [x] add_purchase.html dual-submit? → **Simplify** — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`
|
||||
- [x] GameStatusChange modal or full-page? → **Modal** — trigger via HTMX, same pattern as delete-game
|
||||
- [x] .flowbite-input class? → **Remove entirely** — rely on element-level `@apply` in `input.css`
|
||||
|
||||
## Decision Summary
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Section headers on simple forms | Yes, all forms get them |
|
||||
| Layout approach for multi-column | Flexbox with wrap |
|
||||
| Layout mechanism in add.html | Option A: `c-section` cotton component |
|
||||
| Purchase dual-submit | Simplify — single submit button, same as Game |
|
||||
| GameStatusChange delete | Convert to modal (HTMX-triggered) |
|
||||
| .flowbite-input class | Remove — preserve styling via element-level `@apply` in `input.css` |
|
||||
| `modal.html` component | Rewrite with form wrapping, body + footer slots, reusable close button ✓ Implemented
|
||||
|
||||
## Build Step
|
||||
|
||||
After any CSS changes to `common/input.css`, the compiled output must be rebuilt:
|
||||
|
||||
- **`make css`** — one-shot build: `npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css`
|
||||
- **`make dev`** — watch mode: Tailwind rebuilds automatically on every `input.css` save
|
||||
|
||||
Running `make dev` is sufficient for development since it concurrently runs Django and the CSS watcher.
|
||||
Only use `make css` if you only want to rebuild CSS without starting the dev server.
|
||||
|
||||
**Important**: Legacy CSS removals (`.basic-button`, `#button-container`, `.flowbite-input`) will only take effect in the browser after a rebuild. The old compiled `base.css` will still contain them until rebuilt.
|
||||
+17
-13
@@ -1,19 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Apply database migrations
|
||||
set -euo pipefail
|
||||
echo "Apply database migrations"
|
||||
poetry run python manage.py migrate
|
||||
|
||||
echo "Collect static files"
|
||||
poetry run python manage.py collectstatic --clear --no-input
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-100}
|
||||
|
||||
_term() {
|
||||
echo "Caught SIGTERM signal!"
|
||||
kill -SIGTERM "$gunicorn_pid"
|
||||
}
|
||||
trap _term SIGTERM
|
||||
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
|
||||
|
||||
echo "Starting app"
|
||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
|
||||
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
||||
chmod 755 /home/timetracker/app
|
||||
chmod 755 /home/timetracker/app/.venv
|
||||
|
||||
wait "$gunicorn_pid"
|
||||
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
|
||||
|
||||
+9
-2
@@ -1,11 +1,18 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
|
||||
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(Edition)
|
||||
admin.site.register(Device)
|
||||
admin.site.register(ExchangeRate)
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
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
|
||||
|
||||
|
||||
class GameOption(Schema): # mirrors SearchSelectOption
|
||||
value: int
|
||||
label: str
|
||||
data: dict
|
||||
|
||||
|
||||
@game_router.get("/search", response=list[GameOption])
|
||||
def search_games(request, q: str = "", limit: int = 10):
|
||||
qs = Game.objects.select_related("platform").order_by("sort_name")
|
||||
if q:
|
||||
qs = qs.filter(Q(name__icontains=q) | Q(sort_name__icontains=q))
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in qs[:limit]
|
||||
]
|
||||
|
||||
|
||||
@game_router.patch("/{game_id}/status", response={204: None})
|
||||
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
setattr(game, "status", payload.status)
|
||||
game.save()
|
||||
messages.success(request, "Status updated")
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
@playevent_router.get("/", response=List[PlayEventOut])
|
||||
def list_playevents(request):
|
||||
return PlayEvent.objects.all()
|
||||
|
||||
|
||||
@playevent_router.post("/", response={201: PlayEventOut})
|
||||
def create_playevent(request, payload: PlayEventIn):
|
||||
playevent = PlayEvent.objects.create(**payload.dict())
|
||||
messages.success(request, "Game played!")
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
|
||||
def get_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
|
||||
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
for attr, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(playevent, attr, value)
|
||||
playevent.save()
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.delete("/{playevent_id}", response={204: None})
|
||||
def delete_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
playevent.delete()
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
||||
api.add_router("/games", game_router)
|
||||
|
||||
session_router = Router()
|
||||
|
||||
|
||||
class SessionDeviceUpdate(Schema):
|
||||
device_id: int
|
||||
|
||||
|
||||
@session_router.patch("/{session_id}/device", response={204: None})
|
||||
def partial_update_session_device(
|
||||
request, session_id: int, payload: SessionDeviceUpdate
|
||||
):
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
session.device_id = payload.device_id
|
||||
session.save()
|
||||
messages.success(request, "Device updated")
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
api.add_router("/session", session_router)
|
||||
@@ -1,6 +1,46 @@
|
||||
# from datetime import timedelta
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.management import call_command
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
# from django.utils.timezone import now
|
||||
|
||||
|
||||
class GamesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "games"
|
||||
|
||||
def ready(self):
|
||||
import games.signals # noqa: F401
|
||||
|
||||
post_migrate.connect(schedule_tasks, sender=self)
|
||||
|
||||
|
||||
def schedule_tasks(sender, **kwargs):
|
||||
# from django_q.models import Schedule
|
||||
# from django_q.tasks import schedule
|
||||
|
||||
# if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
# schedule(
|
||||
# "games.tasks.convert_prices",
|
||||
# name="Update converted prices",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
# if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
# schedule(
|
||||
# "games.tasks.calculate_price_per_game",
|
||||
# name="Update price per game",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
from games.models import ExchangeRate
|
||||
|
||||
if not ExchangeRate.objects.exists():
|
||||
print("ExchangeRate table is empty. Loading fixture...")
|
||||
call_command("loaddata", "exchangerates.yaml")
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
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 django.db.models import Q
|
||||
|
||||
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:
|
||||
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:
|
||||
"""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
|
||||
|
||||
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:
|
||||
from datetime import timedelta
|
||||
|
||||
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:
|
||||
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
|
||||
@@ -2,27 +2,34 @@
|
||||
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
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
+280
-24
@@ -1,22 +1,132 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
|
||||
from common.components import (
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
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_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
|
||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
|
||||
|
||||
def _game_options(values) -> list[SearchSelectOption]:
|
||||
"""Resolve game ids (or instances) to SearchSelectOptions via one pk__in query."""
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in Game.objects.filter(pk__in=values).select_related("platform")
|
||||
]
|
||||
|
||||
|
||||
class SearchSelectWidget(forms.Widget):
|
||||
"""Thin Django adapter that renders a `SearchSelect()` component.
|
||||
|
||||
The only place that knows about Django/forms — the component itself stays
|
||||
reusable outside forms.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_url,
|
||||
multi_select=False,
|
||||
items_visible=5,
|
||||
items_scroll=10,
|
||||
always_visible=False,
|
||||
placeholder="Search…",
|
||||
attrs=None,
|
||||
):
|
||||
super().__init__(attrs)
|
||||
self.search_url = search_url
|
||||
self.multi_select = multi_select
|
||||
self.items_visible = items_visible
|
||||
self.items_scroll = items_scroll
|
||||
self.always_visible = always_visible
|
||||
self.placeholder = placeholder
|
||||
|
||||
@staticmethod
|
||||
def _values(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value if v not in (None, "")]
|
||||
return [value] if value not in (None, "") else []
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
selected = searchselect_selected(self._values(value), _game_options)
|
||||
return SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SearchSelectMultiple(SearchSelectWidget):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, "getlist"):
|
||||
return data.getlist(name)
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
# purchase = forms.ModelChoiceField(
|
||||
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
|
||||
# )
|
||||
purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.order_by("edition__name"),
|
||||
widget=autofocus_select_widget,
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(search_url="/api/games/search"),
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -26,25 +136,80 @@ class SessionForm(forms.ModelForm):
|
||||
}
|
||||
model = Session
|
||||
fields = [
|
||||
"purchase",
|
||||
"game",
|
||||
"timestamp_start",
|
||||
"timestamp_end",
|
||||
"duration_manual",
|
||||
"emulated",
|
||||
"device",
|
||||
"note",
|
||||
"mark_as_played",
|
||||
]
|
||||
|
||||
def save(self, commit=True):
|
||||
session = super().save(commit=False)
|
||||
if self.cleaned_data.get("mark_as_played"):
|
||||
game_instance = session.game
|
||||
if game_instance.status == "u":
|
||||
game_instance.status = "p"
|
||||
if commit:
|
||||
game_instance.save()
|
||||
if commit:
|
||||
session.save()
|
||||
return session
|
||||
|
||||
class EditionChoiceField(forms.ModelChoiceField):
|
||||
|
||||
def related_purchase_queryset():
|
||||
"""GAME purchases annotated with their first game's name.
|
||||
|
||||
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
|
||||
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
|
||||
query per option (700+ on a large library). Annotating the first game's
|
||||
name via a subquery lets the choice field build labels without those
|
||||
per-row queries.
|
||||
"""
|
||||
first_game_name = Subquery(
|
||||
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
|
||||
)
|
||||
return Purchase.objects.filter(type=Purchase.GAME).annotate(
|
||||
_first_game_name=first_game_name
|
||||
)
|
||||
|
||||
|
||||
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.name} ({obj.platform}, {obj.year_released})"
|
||||
# Mirrors Purchase.standardized_name but reads the annotated first-game
|
||||
# name instead of querying first_game per option.
|
||||
name = obj.name or getattr(obj, "_first_game_name", None)
|
||||
return name or obj.standardized_name
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
edition = EditionChoiceField(
|
||||
queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectMultiple(search_url="/api/games/search", multi_select=True),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = RelatedPurchaseChoiceField(
|
||||
queryset=related_purchase_queryset(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
price_currency = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"x-mask": "aaa",
|
||||
"placeholder": "CZK",
|
||||
"x-data": "",
|
||||
"class": "uppercase",
|
||||
}
|
||||
),
|
||||
label="Currency",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
widgets = {
|
||||
@@ -53,38 +218,84 @@ class PurchaseForm(forms.ModelForm):
|
||||
}
|
||||
model = Purchase
|
||||
fields = [
|
||||
"edition",
|
||||
"games",
|
||||
"platform",
|
||||
"date_purchased",
|
||||
"date_refunded",
|
||||
"infinite",
|
||||
"price",
|
||||
"price_currency",
|
||||
"ownership_type",
|
||||
"type",
|
||||
"related_purchase",
|
||||
"name",
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
purchase_type = cleaned_data.get("type")
|
||||
related_purchase = cleaned_data.get("related_purchase")
|
||||
name = cleaned_data.get("name")
|
||||
|
||||
class EditionForm(forms.ModelForm):
|
||||
game = forms.ModelChoiceField(
|
||||
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
# Set the type on the instance to use get_type_display()
|
||||
# This is safe because we're not saving the instance.
|
||||
self.instance.type = purchase_type
|
||||
|
||||
class Meta:
|
||||
model = Edition
|
||||
fields = ["game", "name", "platform", "year_released", "wikidata"]
|
||||
if purchase_type != Purchase.GAME:
|
||||
type_display = self.instance.get_type_display()
|
||||
if not related_purchase:
|
||||
self.add_error(
|
||||
"related_purchase",
|
||||
f"{type_display} must have a related purchase.",
|
||||
)
|
||||
if not name:
|
||||
self.add_error("name", f"{type_display} must have a name.")
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class IncludeNameSelect(forms.Select):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if value:
|
||||
option["attrs"]["data-name"] = value.instance.name
|
||||
option["attrs"]["data-year"] = value.instance.year_released
|
||||
return option
|
||||
|
||||
|
||||
class GameModelChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
# Use sort_name as the label for the option
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Game
|
||||
fields = ["name", "wikidata"]
|
||||
fields = [
|
||||
"name",
|
||||
"sort_name",
|
||||
"platform",
|
||||
"original_year_released",
|
||||
"year_released",
|
||||
"status",
|
||||
"mastered",
|
||||
"wikidata",
|
||||
]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlatformForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ["name", "group"]
|
||||
fields = [
|
||||
"name",
|
||||
"icon",
|
||||
"group",
|
||||
]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
@@ -93,3 +304,48 @@ class DeviceForm(forms.ModelForm):
|
||||
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,66 @@
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages as django_messages
|
||||
from django.contrib.messages import constants as message_constants
|
||||
|
||||
MESSAGE_LEVEL_MAP = {
|
||||
message_constants.DEBUG: "debug",
|
||||
message_constants.INFO: "info",
|
||||
message_constants.SUCCESS: "success",
|
||||
message_constants.WARNING: "warning",
|
||||
message_constants.ERROR: "error",
|
||||
}
|
||||
|
||||
|
||||
class HTMXMessagesMiddleware:
|
||||
"""
|
||||
Converts Django messages into HX-Trigger headers so toasts display
|
||||
automatically without changes to views.
|
||||
|
||||
Works for HTMX requests (processed natively by HTMX client),
|
||||
vanilla fetch() calls using fetchWithHtmxTriggers(), and is harmless
|
||||
for full-page loads (browsers ignore HX-Trigger).
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Skip HX-Trigger and don't consume messages if there's an HX-Redirect
|
||||
# so the message persists in the session for the redirect target page
|
||||
if "HX-Redirect" in response:
|
||||
return response
|
||||
|
||||
min_level = (
|
||||
message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
)
|
||||
backend = django_messages.get_messages(request)
|
||||
if hasattr(backend, "_set_level") and backend._get_level() > min_level:
|
||||
backend._set_level(min_level)
|
||||
messages = list(backend)
|
||||
if not messages:
|
||||
return response
|
||||
|
||||
triggers = []
|
||||
for msg in messages:
|
||||
toast_type = MESSAGE_LEVEL_MAP.get(msg.level, "info")
|
||||
triggers.append(
|
||||
{
|
||||
"message": msg.message,
|
||||
"type": toast_type,
|
||||
}
|
||||
)
|
||||
|
||||
if triggers:
|
||||
# Use last message (most recent) as the primary toast
|
||||
trigger = triggers[-1]
|
||||
response["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": trigger,
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now
|
||||
from django_q.models import Schedule
|
||||
from django_q.tasks import schedule
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Manually schedule the next update_converted_prices task"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
schedule(
|
||||
"games.tasks.convert_prices",
|
||||
name="Update converted prices",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Scheduled the update_converted_prices task.")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Task is already scheduled."))
|
||||
@@ -1,18 +1,18 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 18:27
|
||||
# Generated by Django 5.1.5 on 2025-01-29 21:26
|
||||
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
name="Device",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
@@ -24,7 +24,22 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("wikidata", models.CharField(max_length=50)),
|
||||
(
|
||||
"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(
|
||||
@@ -40,9 +55,82 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("group", 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=[
|
||||
@@ -57,19 +145,75 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
("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)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
"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(
|
||||
@@ -85,17 +229,42 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
("timestamp_start", models.DateTimeField()),
|
||||
("timestamp_end", models.DateTimeField()),
|
||||
("duration_manual", models.DurationField(blank=True, null=True)),
|
||||
("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)),
|
||||
(
|
||||
"purchase",
|
||||
"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,
|
||||
to="games.purchase",
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "timestamp_start",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 18:55
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 23:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0002_alter_session_duration_manual"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0002_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 14:49
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0003_alter_session_duration_manual_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:57
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
def initialize_num_purchases(apps, schema_editor):
|
||||
Purchase = apps.get_model("games", "Purchase")
|
||||
purchases = Purchase.objects.annotate(num_games=Count("games"))
|
||||
|
||||
for purchase in purchases:
|
||||
purchase.num_purchases = purchase.num_games
|
||||
purchase.save(update_fields=["num_purchases"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0003_purchase_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="num_purchases",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.RunPython(initialize_num_purchases),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 17:43
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_duration_calculated_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_calculated == None:
|
||||
session.duration_calculated = timedelta(0)
|
||||
session.save()
|
||||
|
||||
|
||||
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_calculated == timedelta(0):
|
||||
session.duration_calculated = None
|
||||
session.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0004_alter_session_duration_manual"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_duration_calculated_none_to_zero,
|
||||
revert_set_duration_calculated_none_to_zero,
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-01 19:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_finished_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0004_purchase_num_purchases"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="mastered",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
default="u",
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_finished_status),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-01 12:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0005_game_mastered_game_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="sort_name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default="", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="platform",
|
||||
name="group",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="converted_currency",
|
||||
field=models.CharField(blank=True, default="", max_length=3),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="games",
|
||||
field=models.ManyToManyField(related_name="purchases", to="games.game"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="related_purchase",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="note",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 18:04
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_duration_manual_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_manual == None:
|
||||
session.duration_manual = timedelta(0)
|
||||
session.save()
|
||||
|
||||
|
||||
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_manual == timedelta(0):
|
||||
session.duration_manual = None
|
||||
session.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0005_auto_20230109_1843"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_duration_manual_none_to_zero,
|
||||
revert_set_duration_manual_none_to_zero,
|
||||
)
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-19 18:30
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0006_auto_20230109_1904"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="platform",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="purchase",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-17 07:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Edition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-19 13:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Min
|
||||
|
||||
|
||||
def copy_year_released(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.update(original_year_released=F("year_released"))
|
||||
|
||||
|
||||
def set_abandoned_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game = apps.get_model("games", "Game")
|
||||
PlayEvent = apps.get_model("games", "PlayEvent")
|
||||
|
||||
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
|
||||
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
|
||||
|
||||
finished = Game.objects.filter(purchases__date_finished__isnull=False)
|
||||
|
||||
for game in finished:
|
||||
for purchase in game.purchases.all():
|
||||
first_session = game.sessions.filter(
|
||||
timestamp_start__gte=purchase.date_purchased
|
||||
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
|
||||
first_session_date = first_session.date() if first_session else None
|
||||
if purchase.date_finished:
|
||||
play_event = PlayEvent(
|
||||
game=game,
|
||||
started=first_session_date
|
||||
if first_session_date
|
||||
else purchase.date_purchased,
|
||||
ended=purchase.date_finished,
|
||||
)
|
||||
play_event.save()
|
||||
|
||||
|
||||
def create_game_status_changes(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
GameStatusChange = apps.get_model("games", "GameStatusChange")
|
||||
|
||||
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
|
||||
for game in Game.objects.filter(sessions__isnull=False).distinct():
|
||||
if game.sessions.exists():
|
||||
earliest_session = game.sessions.earliest()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="u",
|
||||
new_status="p",
|
||||
timestamp=earliest_session.timestamp_start,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_dropped,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_refunded,
|
||||
)
|
||||
|
||||
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
|
||||
# consider only the first playevent
|
||||
for game in Game.objects.filter(playevents__isnull=False):
|
||||
first_playevent = game.playevents.first()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="f",
|
||||
timestamp=first_playevent.ended,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0007_game_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="original_year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.RunPython(copy_year_released),
|
||||
migrations.CreateModel(
|
||||
name="GameStatusChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"old_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"new_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
("timestamp", models.DateTimeField(null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="status_changes",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("started", models.DateField(blank=True, null=True)),
|
||||
("ended", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"days_to_finish",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.RawSQL(
|
||||
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
),
|
||||
),
|
||||
("note", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="playevents",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(set_abandoned_status),
|
||||
migrations.RunPython(create_game_status_changes),
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 18:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_edition_of_game(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
Platform = apps.get_model("games", "Platform")
|
||||
first_platform = Platform.objects.first()
|
||||
all_games = Game.objects.all()
|
||||
all_editions = Edition.objects.all()
|
||||
for game in all_games:
|
||||
existing_edition = None
|
||||
try:
|
||||
existing_edition = all_editions.objects.get(game=game.id)
|
||||
except:
|
||||
pass
|
||||
if existing_edition == None:
|
||||
edition = Edition()
|
||||
edition.id = game.id
|
||||
edition.game = game
|
||||
edition.name = game.name
|
||||
edition.platform = first_platform
|
||||
edition.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0008_edition"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(create_edition_of_game)]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-20 11:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0008_game_original_year_released_gamestatuschange_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="date_dropped",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="date_finished",
|
||||
),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:06
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0009_create_editions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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,29 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0010_remove_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
django.db.models.functions.comparison.Coalesce(
|
||||
models.F("converted_price"), models.F("price"), 0
|
||||
),
|
||||
"/",
|
||||
models.F("num_purchases"),
|
||||
),
|
||||
output_field=models.FloatField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0010_alter_purchase_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="purchase",
|
||||
old_name="game",
|
||||
new_name="edition",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:30
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0011_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.functions.comparison.Coalesce(
|
||||
django.db.models.expressions.CombinedExpression(
|
||||
models.F("timestamp_end"), "-", models.F("timestamp_start")
|
||||
),
|
||||
0,
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0011_rename_game_purchase_edition"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_currency",
|
||||
field=models.CharField(default="USD", max_length=3),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:33
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Sum
|
||||
|
||||
|
||||
def calculate_game_playtime(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
games = Game.objects.all()
|
||||
for game in games:
|
||||
total_playtime = game.sessions.aggregate(
|
||||
total_playtime=Sum(F("duration_total"))
|
||||
)["total_playtime"]
|
||||
if total_playtime:
|
||||
game.playtime = total_playtime
|
||||
game.save(update_fields=["playtime"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0012_alter_session_duration_calculated"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="playtime",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), editable=False
|
||||
),
|
||||
),
|
||||
migrations.RunPython(calculate_game_playtime),
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0012_purchase_price_purchase_price_currency"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="ownership_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0013_purchase_ownership_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pc", "PC"),
|
||||
("co", "Console"),
|
||||
("ha", "Handheld"),
|
||||
("mo", "Mobile"),
|
||||
("sbc", "Single-board computer"),
|
||||
],
|
||||
default="pc",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="device",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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,43 @@
|
||||
# Generated by Django 5.1.7 on 2026-01-15 15:37
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0014_session_duration_total"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="date_purchased",
|
||||
field=models.DateField(verbose_name="Purchased"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="date_refunded",
|
||||
field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True,
|
||||
default=datetime.timedelta(0),
|
||||
null=True,
|
||||
verbose_name="Manual duration",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-20 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0014_device_session_device"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="edition",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default=None, max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="edition",
|
||||
name="year_released",
|
||||
field=models.IntegerField(default=2023),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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,48 @@
|
||||
# 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,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0017_add_filter_preset'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Start'),
|
||||
),
|
||||
]
|
||||
+431
-64
@@ -1,31 +1,145 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Q, Sum
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.fields.generated import GeneratedField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.template.defaultfilters import floatformat, pluralize, slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from common.time import format_duration
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import F, Manager, Sum
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
class Meta:
|
||||
unique_together = [["name", "platform", "year_released"]]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
wikidata = models.CharField(max_length=50)
|
||||
sort_name = models.CharField(max_length=255, blank=True, default="")
|
||||
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
original_year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
wikidata = models.CharField(max_length=50, blank=True, default="")
|
||||
platform = models.ForeignKey(
|
||||
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
||||
)
|
||||
|
||||
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Status(models.TextChoices):
|
||||
UNPLAYED = (
|
||||
"u",
|
||||
"Unplayed",
|
||||
)
|
||||
PLAYED = (
|
||||
"p",
|
||||
"Played",
|
||||
)
|
||||
FINISHED = (
|
||||
"f",
|
||||
"Finished",
|
||||
)
|
||||
RETIRED = (
|
||||
"r",
|
||||
"Retired",
|
||||
)
|
||||
ABANDONED = (
|
||||
"a",
|
||||
"Abandoned",
|
||||
)
|
||||
|
||||
status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED)
|
||||
mastered = models.BooleanField(default=False)
|
||||
|
||||
session_average: float | int | timedelta | None
|
||||
session_count: int | None
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def search_label(self) -> str:
|
||||
return f"{self.sort_name} ({self.platform}, {self.year_released})"
|
||||
|
||||
class Edition(models.Model):
|
||||
game = models.ForeignKey("Game", on_delete=models.CASCADE)
|
||||
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)
|
||||
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
|
||||
year_released = models.IntegerField(default=datetime.today().year)
|
||||
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||
group = models.CharField(max_length=255, blank=True, default="")
|
||||
icon = models.SlugField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.icon:
|
||||
self.icon = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PurchaseQueryset(models.QuerySet):
|
||||
def refunded(self):
|
||||
return self.filter(date_refunded__isnull=False)
|
||||
|
||||
def not_refunded(self):
|
||||
return self.filter(date_refunded__isnull=True)
|
||||
|
||||
def games_only(self):
|
||||
return self.filter(type=Purchase.GAME)
|
||||
|
||||
def finished(self):
|
||||
return self.filter(
|
||||
Q(games__status="f") | Q(games__playevents__ended__isnull=False)
|
||||
).distinct()
|
||||
|
||||
def abandoned(self):
|
||||
return self.filter(games__status="a").distinct()
|
||||
|
||||
def dropped(self):
|
||||
return self.filter(
|
||||
Q(games__status="a") | Q(date_refunded__isnull=False)
|
||||
).distinct()
|
||||
|
||||
|
||||
class Purchase(models.Model):
|
||||
PHYSICAL = "ph"
|
||||
@@ -46,30 +160,103 @@ class Purchase(models.Model):
|
||||
(DEMO, "Demo"),
|
||||
(PIRATED, "Pirated"),
|
||||
]
|
||||
GAME = "game"
|
||||
DLC = "dlc"
|
||||
SEASONPASS = "season_pass"
|
||||
BATTLEPASS = "battle_pass"
|
||||
TYPES = [
|
||||
(GAME, "Game"),
|
||||
(DLC, "DLC"),
|
||||
(SEASONPASS, "Season Pass"),
|
||||
(BATTLEPASS, "Battle Pass"),
|
||||
]
|
||||
|
||||
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
|
||||
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
|
||||
date_purchased = models.DateField()
|
||||
date_refunded = models.DateField(blank=True, null=True)
|
||||
price = models.IntegerField(default=0)
|
||||
objects = PurchaseQueryset().as_manager()
|
||||
|
||||
games = models.ManyToManyField(Game, related_name="purchases")
|
||||
|
||||
platform = models.ForeignKey(
|
||||
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
|
||||
)
|
||||
date_purchased = models.DateField(verbose_name="Purchased")
|
||||
date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
|
||||
infinite = models.BooleanField(default=False)
|
||||
price = models.FloatField(default=0)
|
||||
price_currency = models.CharField(max_length=3, default="USD")
|
||||
converted_price = models.FloatField(null=True)
|
||||
converted_currency = models.CharField(max_length=3, blank=True, default="")
|
||||
needs_price_update = models.BooleanField(default=True, db_index=True)
|
||||
price_per_game = GeneratedField(
|
||||
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
||||
output_field=models.FloatField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
num_purchases = models.IntegerField(default=0)
|
||||
ownership_type = models.CharField(
|
||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||
)
|
||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||
name = models.CharField(max_length=255, blank=True, default="")
|
||||
related_purchase = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
default=None,
|
||||
null=True,
|
||||
related_name="related_purchases",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def standardized_price(self):
|
||||
return (
|
||||
f"{floatformat(self.converted_price, 0)} {self.converted_currency}"
|
||||
if self.converted_price
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def has_one_item(self):
|
||||
return self.games.count() == 1
|
||||
|
||||
@property
|
||||
def standardized_name(self):
|
||||
return self.name or self.first_game.name
|
||||
|
||||
@property
|
||||
def first_game(self):
|
||||
return self.games.first()
|
||||
|
||||
def __str__(self):
|
||||
platform_info = self.platform
|
||||
if self.platform != self.edition.platform:
|
||||
platform_info = f"{self.edition.platform} version on {self.platform}"
|
||||
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
|
||||
return self.standardized_name
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
additional_info = [
|
||||
str(item)
|
||||
for item in [
|
||||
f"{self.num_purchases} game{pluralize(self.num_purchases)}",
|
||||
self.date_purchased,
|
||||
self.standardized_price,
|
||||
]
|
||||
if item
|
||||
]
|
||||
return f"{self.standardized_name} ({', '.join(additional_info)})"
|
||||
|
||||
class Platform(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
group = models.CharField(max_length=255)
|
||||
def is_game(self):
|
||||
return self.type == self.GAME
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
def refund(self):
|
||||
self.date_refunded = timezone.now()
|
||||
self.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.type != Purchase.GAME and not self.related_purchase:
|
||||
raise ValidationError(
|
||||
f"{self.get_type_display()} must have a related purchase."
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SessionQuerySet(models.QuerySet):
|
||||
@@ -82,68 +269,248 @@ class SessionQuerySet(models.QuerySet):
|
||||
)
|
||||
return result["duration"]
|
||||
|
||||
def calculated_duration_formatted(self):
|
||||
return format_duration(self.calculated_duration_unformatted())
|
||||
|
||||
def calculated_duration_unformatted(self):
|
||||
result = self.aggregate(duration=Sum(F("duration_calculated")))
|
||||
return result["duration"]
|
||||
|
||||
def without_manual(self):
|
||||
return self.exclude(duration_calculated__iexact=0)
|
||||
|
||||
def only_manual(self):
|
||||
return self.filter(duration_calculated__iexact=0)
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
|
||||
timestamp_start = models.DateTimeField()
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True)
|
||||
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
|
||||
duration_calculated = models.DurationField(blank=True, null=True)
|
||||
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
class Meta:
|
||||
get_latest_by = "timestamp_start"
|
||||
|
||||
game = models.ForeignKey(
|
||||
Game,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
default=None,
|
||||
related_name="sessions",
|
||||
)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start", db_index=True)
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
|
||||
duration_manual = models.DurationField(
|
||||
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
|
||||
)
|
||||
duration_calculated = GeneratedField(
|
||||
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
|
||||
output_field=models.DurationField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
duration_total = GeneratedField(
|
||||
expression=F("duration_calculated") + F("duration_manual"),
|
||||
output_field=models.DurationField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
"Device",
|
||||
on_delete=models.SET_DEFAULT,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
)
|
||||
note = models.TextField(blank=True, default="")
|
||||
emulated = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
modified_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = SessionQuerySet.as_manager()
|
||||
|
||||
def __str__(self):
|
||||
mark = ", manual" if self.duration_manual != None else ""
|
||||
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
mark = "*" if self.is_manual() else ""
|
||||
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
|
||||
def finish_now(self):
|
||||
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
|
||||
def start_now():
|
||||
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
|
||||
|
||||
def duration_seconds(self) -> timedelta:
|
||||
manual = timedelta(0)
|
||||
calculated = timedelta(0)
|
||||
if not self.duration_manual in (None, 0, timedelta(0)):
|
||||
manual = self.duration_manual
|
||||
if self.timestamp_end != None and self.timestamp_start != None:
|
||||
calculated = self.timestamp_end - self.timestamp_start
|
||||
return timedelta(seconds=(manual + calculated).total_seconds())
|
||||
self.timestamp_end = timezone.now()
|
||||
|
||||
def duration_formatted(self) -> str:
|
||||
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
|
||||
result = format_duration(self.duration_total, "%02.1H")
|
||||
return result
|
||||
|
||||
@property
|
||||
def duration_sum(self) -> str:
|
||||
return Session.objects.all().total_duration_formatted()
|
||||
def duration_formatted_with_mark(self) -> str:
|
||||
mark = "*" if self.is_manual() else ""
|
||||
return f"{self.duration_formatted()}{mark}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.timestamp_start != None and self.timestamp_end != None:
|
||||
self.duration_calculated = self.timestamp_end - self.timestamp_start
|
||||
else:
|
||||
self.duration_calculated = timedelta(0)
|
||||
def is_manual(self) -> bool:
|
||||
return not self.duration_manual == timedelta(0)
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not isinstance(self.duration_manual, timedelta):
|
||||
self.duration_manual = timedelta(0)
|
||||
|
||||
if not self.device:
|
||||
default_device, _ = Device.objects.get_or_create(
|
||||
type=Device.UNKNOWN, defaults={"name": "Unknown"}
|
||||
)
|
||||
self.device = default_device
|
||||
super(Session, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
PC = "pc"
|
||||
CONSOLE = "co"
|
||||
HANDHELD = "ha"
|
||||
MOBILE = "mo"
|
||||
SBC = "sbc"
|
||||
PC = "PC"
|
||||
CONSOLE = "Console"
|
||||
HANDHELD = "Handheld"
|
||||
MOBILE = "Mobile"
|
||||
SBC = "Single-board computer"
|
||||
UNKNOWN = "Unknown"
|
||||
DEVICE_TYPES = [
|
||||
(PC, "PC"),
|
||||
(CONSOLE, "Console"),
|
||||
(HANDHELD, "Handheld"),
|
||||
(MOBILE, "Mobile"),
|
||||
(SBC, "Single-board computer"),
|
||||
(UNKNOWN, "Unknown"),
|
||||
]
|
||||
name = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
|
||||
type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_type_display()})"
|
||||
return f"{self.name} ({self.type})"
|
||||
|
||||
|
||||
class ExchangeRate(models.Model):
|
||||
currency_from = models.CharField(max_length=255)
|
||||
currency_to = models.CharField(max_length=255)
|
||||
year = models.PositiveIntegerField()
|
||||
rate = models.FloatField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ("currency_from", "currency_to", "year")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
|
||||
|
||||
|
||||
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
|
||||
exchange_rate = None
|
||||
result = ExchangeRate.objects.filter(
|
||||
currency_from=currency_from, currency_to=currency_to, year=year
|
||||
)
|
||||
if result:
|
||||
exchange_rate = result[0].rate
|
||||
else:
|
||||
try:
|
||||
# this API endpoint only accepts lowercase currency string
|
||||
response = requests.get(
|
||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
currency_from_data = data.get(currency_from.lower())
|
||||
rate = currency_from_data.get(currency_to.lower())
|
||||
|
||||
if rate:
|
||||
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
||||
exchange_rate = ExchangeRate.objects.create(
|
||||
currency_from=currency_from,
|
||||
currency_to=currency_to,
|
||||
year=year,
|
||||
rate=floatformat(rate, 2),
|
||||
)
|
||||
exchange_rate = exchange_rate.rate
|
||||
else:
|
||||
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||
except requests.RequestException as e:
|
||||
logger.info(
|
||||
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||
)
|
||||
return exchange_rate
|
||||
|
||||
|
||||
class PlayEvent(models.Model):
|
||||
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
|
||||
started = models.DateField(null=True, blank=True)
|
||||
ended = models.DateField(null=True, blank=True)
|
||||
days_to_finish = GeneratedField(
|
||||
# special cases:
|
||||
# missing ended, started, or both = 0
|
||||
# same day = 1 day to finish
|
||||
expression=RawSQL(
|
||||
"""
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN date(ended) = date(started) THEN 1
|
||||
ELSE julianday(ended) - julianday(started)
|
||||
END, 0
|
||||
)
|
||||
""",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
blank=True,
|
||||
)
|
||||
note = models.CharField(max_length=255, blank=True, default="")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
# class PlayMarker(models.Model):
|
||||
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
|
||||
# played_since = models.DurationField()
|
||||
# played_total = models.DurationField()
|
||||
# note = models.CharField(max_length=255)
|
||||
|
||||
|
||||
class GameStatusChange(models.Model):
|
||||
"""
|
||||
Tracks changes to the status of a Game.
|
||||
"""
|
||||
|
||||
game = models.ForeignKey(
|
||||
Game, on_delete=models.CASCADE, related_name="status_changes"
|
||||
)
|
||||
old_status = models.CharField(
|
||||
max_length=1, choices=Game.Status.choices, blank=True, null=True
|
||||
)
|
||||
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
|
||||
timestamp = models.DateTimeField(null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-timestamp"]
|
||||
|
||||
|
||||
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")
|
||||
+5412
-1396
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,29 +1,24 @@
|
||||
/**
|
||||
* @description Sync select field with input field until user focuses it.
|
||||
* @param {HTMLSelectElement} sourceElementSelector
|
||||
* @param {HTMLInputElement} targetElementSelector
|
||||
*/
|
||||
function syncSelectInputUntilChanged(
|
||||
sourceElementSelector,
|
||||
targetElementSelector
|
||||
) {
|
||||
const sourceElement = document.querySelector(sourceElementSelector);
|
||||
const targetElement = document.querySelector(targetElementSelector);
|
||||
function sourceElementHandler(event) {
|
||||
let selected = event.target.value;
|
||||
let selectedValue = document.querySelector(
|
||||
`#id_game option[value='${selected}']`
|
||||
).textContent;
|
||||
targetElement.value = selectedValue;
|
||||
}
|
||||
function targetElementHandler(event) {
|
||||
sourceElement.removeEventListener("change", sourceElementHandler);
|
||||
}
|
||||
import { syncSelectInputUntilChanged } from "./utils.js";
|
||||
|
||||
sourceElement.addEventListener("change", sourceElementHandler);
|
||||
targetElement.addEventListener("focus", targetElementHandler);
|
||||
}
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
syncSelectInputUntilChanged("#id_game", "#id_name");
|
||||
});
|
||||
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,45 @@
|
||||
import { getEl, disableElementsWhenTrue } from "./utils.js";
|
||||
|
||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||
|
||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||
// react to its custom "search-select:change" event instead of syncing a select.
|
||||
document.addEventListener("search-select:change", (event) => {
|
||||
if (event.detail.name !== "games") return;
|
||||
|
||||
// (a) Auto-fill platform from the clicked option's data-platform.
|
||||
const last = event.detail.last;
|
||||
const platformId = last && last.data ? last.data.platform : "";
|
||||
if (platformId) {
|
||||
const platformEl = getEl("#id_platform");
|
||||
if (platformEl) platformEl.value = platformId;
|
||||
}
|
||||
|
||||
// (b) Refresh #id_related_purchase for the currently selected games.
|
||||
const query = event.detail.values
|
||||
.map((value) => "games=" + encodeURIComponent(value))
|
||||
.join("&");
|
||||
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (response.status === 204) return null;
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
if (html === null) return;
|
||||
const target = getEl("#id_related_purchase");
|
||||
if (target) target.outerHTML = html;
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -7,10 +7,14 @@ for (let button of document.querySelectorAll("[data-target]")) {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (type == "now") {
|
||||
targetElement.value = toISOUTCString(new Date);
|
||||
targetElement.value = toISOUTCString(new Date());
|
||||
} else if (type == "copy") {
|
||||
const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
|
||||
document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
|
||||
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,368 @@
|
||||
/**
|
||||
* 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) {
|
||||
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 !== "") {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Range slider — custom draggable handles (no native <input type=range>).
|
||||
*
|
||||
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
|
||||
* range (default) — two handles, min ≤ max constraint
|
||||
* point — single handle, sets both number inputs to the same value
|
||||
*
|
||||
* Handles track-fill positioning and sync between handles and the connected
|
||||
* number inputs (linked via data-target attributes).
|
||||
*/
|
||||
(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 mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
|
||||
var minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target")
|
||||
);
|
||||
var maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target")
|
||||
);
|
||||
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function valueToPercent(value) {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent) {
|
||||
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, value));
|
||||
}
|
||||
|
||||
function getTargetValue(target) {
|
||||
return parseInt(target ? target.value : 0, 10) || dataMin;
|
||||
}
|
||||
function setTargetValue(target, value) {
|
||||
if (target) target.value = value;
|
||||
}
|
||||
|
||||
// ── Track fill positioning ──
|
||||
|
||||
function updateTrackFill() {
|
||||
if (!trackFill) return;
|
||||
var minValue = getTargetValue(minTarget);
|
||||
var maxValue = getTargetValue(maxTarget);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxValue) + "%";
|
||||
} else {
|
||||
var leftPct = valueToPercent(minValue);
|
||||
var widthPct = valueToPercent(maxValue) - leftPct;
|
||||
trackFill.style.left = leftPct + "%";
|
||||
trackFill.style.width = widthPct + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function updateHandles() {
|
||||
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
|
||||
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
|
||||
// ── Dragging ──
|
||||
|
||||
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 value = percentToValue(clamp(pct, 0, 100));
|
||||
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget))
|
||||
);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else {
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget), dataMax)
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
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 number inputs back to handles ──
|
||||
|
||||
function syncFromInputs() {
|
||||
if (mode === "point") {
|
||||
var value =
|
||||
getTargetValue(minTarget) || getTargetValue(maxTarget);
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
if (minTarget)
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
if (maxTarget)
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
var block = slider.closest(".range-slider-block");
|
||||
var toggleButton =
|
||||
block && block.querySelector(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", function () {
|
||||
var newMode = mode === "range" ? "point" : "range";
|
||||
slider.setAttribute("data-mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
var iconRange = toggleButton.querySelector(
|
||||
".range-mode-icon-range"
|
||||
);
|
||||
var iconPoint = toggleButton.querySelector(
|
||||
".range-mode-icon-point"
|
||||
);
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
var dashSpan = block && block.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, getTargetValue(maxTarget));
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
window.initRangeSliders = initAll;
|
||||
})();
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* SearchSelect widget — a search box paired with a dropdown of options.
|
||||
* Multi-select renders chosen items as removable pills (inline with the search
|
||||
* box), each backed by a hidden <input>. Single-select renders no pill: the
|
||||
* committed label lives inside the search box (which doubles as a combobox —
|
||||
* focus clears it to search, picking an option fills it), with a lone hidden
|
||||
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
||||
*
|
||||
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
|
||||
* each widget guarded with el._ssInit.
|
||||
*
|
||||
* The pill / option class strings below are kept byte-identical to the Python
|
||||
* Pill / SearchSelect components so Tailwind generates the classes and
|
||||
* server-rendered and JS-created pills are indistinguishable.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var PILL_CLASS =
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
|
||||
"bg-brand/15 text-heading";
|
||||
var PILL_REMOVE_CLASS =
|
||||
"ml-1 text-body hover:text-heading font-bold cursor-pointer";
|
||||
var OPTION_ROW_CLASS =
|
||||
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
|
||||
|
||||
var DEBOUNCE_MS = 500;
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-search-select]").forEach(function (el) {
|
||||
if (el._ssInit) return;
|
||||
el._ssInit = true;
|
||||
initWidget(el);
|
||||
});
|
||||
}
|
||||
|
||||
function initWidget(container) {
|
||||
var search = container.querySelector("[data-ss-search]");
|
||||
var options = container.querySelector("[data-ss-options]");
|
||||
var pills = container.querySelector("[data-ss-pills]");
|
||||
if (!search || !options || !pills) return;
|
||||
|
||||
var name = container.getAttribute("data-name");
|
||||
var searchUrl = container.getAttribute("data-search-url");
|
||||
var multi = container.getAttribute("data-multi") === "true";
|
||||
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
||||
var syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
|
||||
var noResults = options.querySelector("[data-ss-no-results]");
|
||||
var debounceTimer = null;
|
||||
|
||||
function showPanel() {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
function hidePanel() {
|
||||
if (!alwaysVisible) options.classList.add("hidden");
|
||||
}
|
||||
|
||||
function setNoResults(visible) {
|
||||
if (noResults) noResults.classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
function renderRows(items) {
|
||||
options.querySelectorAll("[data-ss-option]").forEach(function (r) {
|
||||
r.remove();
|
||||
});
|
||||
items.slice(0, itemsScroll).forEach(function (item) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
});
|
||||
setNoResults(items.length === 0);
|
||||
showPanel();
|
||||
}
|
||||
|
||||
function buildRow(option) {
|
||||
var row = document.createElement("div");
|
||||
row.setAttribute("data-ss-option", "");
|
||||
row.setAttribute("data-value", option.value);
|
||||
row.setAttribute("data-label", option.label);
|
||||
row.className = OPTION_ROW_CLASS;
|
||||
var data = option.data || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
row.setAttribute("data-" + key, data[key]);
|
||||
});
|
||||
row.textContent = option.label;
|
||||
row._ssOption = option;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Client-side filter of pre-rendered rows ──
|
||||
function filterRows(q) {
|
||||
var lower = q.toLowerCase();
|
||||
var anyVisible = false;
|
||||
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
|
||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
var match = label.indexOf(lower) !== -1;
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) anyVisible = true;
|
||||
});
|
||||
setNoResults(!anyVisible);
|
||||
showPanel();
|
||||
}
|
||||
|
||||
function runSearch() {
|
||||
var q = search.value.trim();
|
||||
if (searchUrl && q) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function () {
|
||||
fetch(searchUrl + "?q=" + encodeURIComponent(q), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(renderRows)
|
||||
.catch(function () {
|
||||
setNoResults(true);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
filterRows(q);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single-select combobox: the search box shows the committed label;
|
||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||
if (!multi) container._ssLabel = search.value;
|
||||
|
||||
search.addEventListener("focus", function () {
|
||||
if (!multi) {
|
||||
// Hide the committed label so the box becomes a fresh search field.
|
||||
search.value = "";
|
||||
container._ssDirty = false;
|
||||
}
|
||||
runSearch();
|
||||
});
|
||||
search.addEventListener("input", function () {
|
||||
if (!multi) container._ssDirty = true;
|
||||
runSearch();
|
||||
});
|
||||
if (!multi) {
|
||||
search.addEventListener("blur", function () {
|
||||
// Defer so an option click (which fires before blur settles) wins.
|
||||
setTimeout(function () {
|
||||
if (container._ssDirty && search.value.trim() === "") {
|
||||
// User intentionally cleared the box → deselect.
|
||||
pills.innerHTML = "";
|
||||
container._ssLabel = "";
|
||||
emitChange(null);
|
||||
} else {
|
||||
// Focused-and-left, or typed a partial query without picking →
|
||||
// restore the committed label (no-op right after a selection).
|
||||
search.value = container._ssLabel || "";
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
// Clicking an option must not blur the input before the click selects.
|
||||
options.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// ── Option click → select ──
|
||||
options.addEventListener("click", function (e) {
|
||||
var row = e.target.closest("[data-ss-option]");
|
||||
if (!row) return;
|
||||
var option = optionFromRow(row);
|
||||
selectOption(option);
|
||||
});
|
||||
|
||||
function optionFromRow(row) {
|
||||
if (row._ssOption) return row._ssOption;
|
||||
var data = {};
|
||||
Object.keys(row.dataset).forEach(function (key) {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
value: row.getAttribute("data-value"),
|
||||
label: row.getAttribute("data-label"),
|
||||
data: data,
|
||||
};
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
if (multi) {
|
||||
if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) {
|
||||
addPill(option);
|
||||
}
|
||||
} else {
|
||||
// Single-select: no pill — show the label in the search box and keep a
|
||||
// lone hidden input under [data-ss-pills] for submission.
|
||||
pills.innerHTML = "";
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
search.value = option.label;
|
||||
container._ssLabel = option.label;
|
||||
container._ssDirty = false;
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
}
|
||||
|
||||
function addPill(option) {
|
||||
pills.appendChild(buildPill(option));
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
}
|
||||
|
||||
function buildPill(option) {
|
||||
var pill = document.createElement("span");
|
||||
pill.className = PILL_CLASS;
|
||||
pill.setAttribute("data-pill", "");
|
||||
pill.setAttribute("data-value", option.value);
|
||||
var data = option.data || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
pill.setAttribute("data-" + key, data[key]);
|
||||
});
|
||||
pill.appendChild(document.createTextNode(option.label));
|
||||
var remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.setAttribute("data-pill-remove", "");
|
||||
remove.className = PILL_REMOVE_CLASS;
|
||||
remove.setAttribute("aria-label", "Remove");
|
||||
remove.textContent = "×";
|
||||
pill.appendChild(remove);
|
||||
return pill;
|
||||
}
|
||||
|
||||
function buildHidden(value) {
|
||||
var input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
return input;
|
||||
}
|
||||
|
||||
// ── Pill × → remove ──
|
||||
pills.addEventListener("click", function (e) {
|
||||
var removeBtn = e.target.closest("[data-pill-remove]");
|
||||
if (!removeBtn) return;
|
||||
var pill = removeBtn.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
var value = pill.getAttribute("data-value");
|
||||
pill.remove();
|
||||
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
|
||||
if (hidden) hidden.remove();
|
||||
emitChange(null);
|
||||
});
|
||||
|
||||
function currentValues() {
|
||||
return Array.prototype.map.call(
|
||||
pills.querySelectorAll('input[type="hidden"]'),
|
||||
function (input) {
|
||||
return input.value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function emitChange(last) {
|
||||
var values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name: name, values: values, last: last },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function syncToUrl(values) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(function (v) {
|
||||
params.append(name, v);
|
||||
});
|
||||
var qs = params.toString();
|
||||
history.replaceState(null, "", qs ? "?" + qs : window.location.pathname);
|
||||
}
|
||||
|
||||
// On init, restore from URL params if the server supplied no selected pills.
|
||||
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
||||
var initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(function (v) {
|
||||
addPill({ value: v, label: v, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!container.contains(e.target)) hidePanel();
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Forward-looking hook (parallels readSelectableFilters): write each widget's
|
||||
// current values to a data-values JSON attribute.
|
||||
window.readSearchSelect = function (form) {
|
||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||
var pills = container.querySelector("[data-ss-pills]");
|
||||
var values = pills
|
||||
? Array.prototype.map.call(
|
||||
pills.querySelectorAll('input[type="hidden"]'),
|
||||
function (input) {
|
||||
return input.value;
|
||||
}
|
||||
)
|
||||
: [];
|
||||
container.setAttribute("data-values", JSON.stringify(values));
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", 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;
|
||||
});
|
||||
};
|
||||
+197
-1
@@ -3,7 +3,7 @@
|
||||
* @param {Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toISOUTCString(date) {
|
||||
function toISOUTCString(date) {
|
||||
function stringAndPad(number) {
|
||||
return number.toString().padStart(2, 0);
|
||||
}
|
||||
@@ -14,3 +14,199 @@ export function toISOUTCString(date) {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ function addToggleButton(targetNode) {
|
||||
targetNode.parentElement.appendChild(manualToggleButton);
|
||||
}
|
||||
|
||||
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
|
||||
const toggleableFields = ["#id_games", "#id_platform"];
|
||||
|
||||
toggleableFields.map((selector) => {
|
||||
addToggleButton(document.querySelector(selector));
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
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
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<table class="mx-auto">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_table }}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" value="Submit"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<table class="mx-auto">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_table }}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" value="Submit"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
{% load static %}
|
||||
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user