Compare commits
	
		
			339 Commits
		
	
	
		
			0.1.2
			...
			fda4913c97
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fda4913c97 | |||
| e85b32e22f | |||
| 2d6d6d24a4 | |||
| 00993a85db | |||
| 4f7e708255 | |||
| 238e4839e0 | |||
| b0ad806a93 | |||
| 453b4fd922 | |||
| bb0d24809e | |||
| 3abd4c4af9 | |||
| 2e5e77b4e5 | |||
| e79cf5de7a | |||
| c15eaca205 | |||
| 496c99ccf1 | |||
| 992622e8d1 | |||
| cabe36c822 | |||
| d84b67c460 | |||
| 1c28950b53 | |||
| b54bcdd9e9 | |||
| 9ec6c958c8 | |||
| 25deac6ea9 | |||
| a5ac10b20d | |||
| 3de40ccad3 | |||
| 6a5dc9b62c | |||
| b6014a72e0 | |||
| 245b47b8b3 | |||
| e33f23c18f | |||
| 33012bc328 | |||
| 447bd4820c | |||
| 72e89dae77 | |||
| 1cd0a8c0fb | |||
| a9a430f856 | |||
| 0ee4c50a24 | |||
| 714f0d97a9 | |||
| d622ddfbf3 | |||
| 86fd40cc4a | |||
| e174850262 | |||
| 6328d835ee | |||
| 34d42e2af5 | |||
| e19caf47bf | |||
| 72998ffc02 | |||
| ba44814474 | |||
| 86f8fde8fa | |||
| 811fec4b11 | |||
| fe6cf2758c | |||
| 1e1372ca56 | |||
| d91c0bc255 | |||
| a14f5d3ae5 | |||
| 4ac13053d5 | |||
| e9311225e7 | |||
| 44c70a5ee7 | |||
| cd804f2c77 | |||
| 15997bd5af | |||
| 880ea93424 | |||
| dc1a9d5c4f | |||
| 51c25659a9 | |||
| 973dda59d2 | |||
| 64edca9ffa | |||
| 86e25b84ab | |||
| edc1d062bc | |||
| 12a517c9fa | |||
| c1882f66e3 | |||
| 1e87e67eb1 | |||
| 84552e088b | |||
| 79dc8ae25c | |||
| cee06e4f64 | |||
| d9b5f0eab2 | |||
| ff28600710 | |||
| 7517bf5f37 | |||
| 780a04d13f | |||
| fd04e9fa77 | |||
| 18902aedac | |||
| f9e37e9b1e | |||
| c747cd1fd8 | |||
| 6a5457191a | |||
| 76f6d0c377 | |||
| ae93703c08 | |||
| c55176090c | |||
| 081b8a92de | |||
| d02a60675f | |||
| 4670568acb | |||
| 4b75a1dea9 | |||
| e2b7ff2e15 | |||
| b94aa49fc3 | |||
| 73a92e5636 | |||
| 42b28665e1 | |||
| 6ba187f8e4 | |||
| a765fd8d00 | |||
| 854e3cc54a | |||
| 2d8eb32e90 | |||
| 1f1ed79ee5 | |||
| 01fd7bad69 | |||
| 44f49e5974 | |||
| 0cf3411f63 | |||
| aa669710e1 | |||
| 242833f886 | |||
| 0cdfd3c298 | |||
| a98b4839dd | |||
| 1999f13cf2 | |||
| 8466f67c86 | |||
| d9fbb4b896 | |||
| 4ff3692606 | |||
| 8289c48896 | |||
| d1b9202337 | |||
| fde93cb875 | |||
| d1c3ac6079 | |||
| d921c2d8a6 | |||
| 52513e1ed8 | |||
| cb380814a7 | |||
| 5ef8c07f30 | |||
| 9573c3b8ff | |||
| c4354a1380 | |||
| a245b6ff0f | |||
| 6329d380b7 | |||
| 76fbc39fed | |||
| 4b6734c173 | |||
| b505b5b430 | |||
| 87553ebdc5 | |||
| ba4fc0cac5 | |||
| 8cb0276215 | |||
| f9a51ee83d | |||
| c9deba7d65 | |||
| c55fbe86b5 | |||
| 0e93993498 | |||
| 9fccdfbff0 | |||
| d78139a5b3 | |||
| 7dc43fbf77 | |||
| 5442926457 | |||
| db4c635260 | |||
| 4a1d08d4df | |||
| c35b539c42 | |||
| bbe5e072b2 | |||
| 6fc2f623dc | |||
| 9481bd5fef | |||
| 4083165123 | |||
| 45bb2681c7 | |||
| dbb8ec3f9a | |||
| 206b5f6d46 | |||
| b7e14ecc83 | |||
| 912e010729 | |||
| a485237456 | |||
| f5faf92ee0 | |||
| 07452d8c43 | |||
| 229a79d266 | |||
| c6ed577fe3 | |||
| 171e4779a3 | |||
| 79f94e5984 | |||
| ccebcb89c6 | |||
| fe0a6b39e3 | |||
| 6a495f951f | |||
| c8646d0a0c | |||
| f2bb15e669 | |||
| c49177d63c | |||
| bd8d30eac1 | |||
| c44d8bf427 | |||
| 3f037b4c7c | |||
| 8783d1fc8e | |||
| 9a1d24dbfd | |||
| 4720660cff | |||
| e158bc0623 | |||
| 8982fc5086 | |||
| 729e1d939b | |||
| 2b4683e489 | |||
| cce810e8cf | |||
| 62cd17f702 | |||
| f31280c682 | |||
| a745d16ec3 | |||
| ae079e36ec | |||
| c8a3212b77 | |||
| d211326c3f | |||
| 270a291f05 | |||
| 13b750ca92 | |||
| 015b6db2f7 | |||
| 667b161fff | |||
| 5958cbf4a6 | |||
| 3b37f2c3f0 | |||
| 4517ff2b5a | |||
| 884ce13e26 | |||
| dd219bae9d | |||
| 60d29090a1 | |||
| 1bc3ca057b | |||
| c2c0886451 | |||
| b0be7b5887 | |||
| 099d989f16 | |||
| a879360ebd | |||
| 866f2526e6 | |||
| ce3c4b55f0 | |||
| c52cd822ae | |||
| cdc6ca1324 | |||
| e7ed349356 | |||
| 5052ca7dbf | |||
| f408bfd927 | |||
| 666dee33ba | |||
| e0b09e051a | |||
| 4552cf7616 | |||
| a614b51d29 | |||
| e67aa3fda1 | |||
| 8423fd02b4 | |||
| 2bd07e5f2d | |||
| 058b83522c | |||
| f13ed8a078 | |||
| 02d5adcb3c | |||
| d6fb16bb74 | |||
| 71b90b8202 | |||
| 3ee36932c3 | |||
| 391fcc79a8 | |||
| 57d4fd7212 | |||
| a5b2854bf6 | |||
| 518c0ecd56 | |||
| a6cd7a3430 | |||
| dba8414fd9 | |||
| 0e2113eefd | |||
| c4b0347f3b | |||
| c6ed21167c | |||
| 4ce15c44fc | |||
| c814b4c2cb | |||
| 11b9c602de | |||
| 9a332593f4 | |||
| 22935721ca | |||
| a2ecdcf44a | |||
| 3c958c4a13 | |||
| 3db1724e22 | |||
| d2a9630b04 | |||
| e3ee832d3f | |||
| 7467e2732d | |||
| 787ee8640f | |||
| ab41222f3c | |||
| 29bf3b1946 | |||
| 3f7ccea2e2 | |||
| b5ffb3586b | |||
| 26d57a238e | |||
| 2d5ad3182c | |||
| 49cc3ea0cc | |||
| 440e1cfb71 | |||
| 1cbd8c5c55 | |||
| bc81a0ee8e | |||
| c5653977ff | |||
| f151730ab6 | |||
| f469a67d94 | |||
| 104ffc9d03 | |||
| a4b13eb247 | |||
| 2307fac83a | |||
| 6b52c0d4c4 | |||
| ff5d8c215d | |||
| cdb3b89b08 | |||
| ffa8198540 | |||
| 0b7da3550c | |||
| e1655d6cfa | |||
| 29c41865d0 | |||
| d21b461726 | |||
| 95489cfb78 | |||
| fa4f1c4810 | |||
| 366c25a1ff | |||
| a3042caa20 | |||
| 7997f9bbb2 | |||
| b78c4ba9c5 | |||
| 1df889c45d | |||
| 468d05a9e2 | |||
| 2640a49734 | |||
| 65c175afb2 | |||
| 0814071a26 | |||
| 5f845f866e | |||
| c3d4697470 | |||
| 77293f03e9 | |||
| 1fa364e2ec | |||
| 4a6f4a2f9a | |||
| 9590988b6a | |||
| 938c82a395 | |||
| 33939f631c | |||
| ac8cd6534a | |||
| 51d8e953c0 | |||
| 2eec677f41 | |||
| f2eb14d3ef | |||
| c337d2200f | |||
| 8a8b05b0bd | |||
| 9446065271 | |||
| 755093845d | |||
| d4ab0596da | |||
| 8dcbe2f0ad | |||
| 25bc74eff1 | |||
| 8a7d083fb2 | |||
| 8296ebcf31 | |||
| 04a4f2e0be | |||
| 4070b4e46e | |||
| 4892218c83 | |||
| 6b00a950ce | |||
| feee9d6dac | |||
| 9654fb017d | |||
| 1741397ee7 | |||
| da0c8d710b | |||
| 215374167b | |||
| 77268ae92f | |||
| c42687a072 | |||
| ca16345374 | |||
| 3a3045be91 | |||
| d40612af72 | |||
| 18e8f93261 | |||
| 56e5dfaa03 | |||
| 2f00be455d | |||
| c3c9ae0632 | |||
| 55c2693f32 | |||
| 972ff67050 | |||
| 8ae99faa8e | |||
| 8e4086ce83 | |||
| 2760068cde | |||
| cef797c333 | |||
| 4d91a76513 | |||
| e51d586255 | |||
| 2553d6f9e6 | |||
| 8cf6270d8f | |||
| 0b1089b0f4 | |||
| 9534492f17 | |||
| 8b7ed90b49 | |||
| 2ce4dd3a0e | |||
| a851b5329a | |||
| 6fa049e1b1 | |||
| 6b7ed0dbb5 | |||
| dd50d6dd40 | |||
| 162f4f3dbf | |||
| e8e6d5bcae | |||
| c5b451a258 | |||
| 163211ab0b | |||
| 64f5668dde | |||
| 465d958d9b | |||
| d8ece979a8 | |||
| 2defdd4657 | |||
| 078f87687f | |||
| 49723831e9 | |||
| 025ea0dd4e | |||
| 97467c7a52 | |||
| 7842d6f45d | |||
| b77089f7ad | |||
| 24f4459318 | |||
| 751182df52 | |||
| 33e136a810 | |||
| 362732c22a | |||
| 8e1c670ffd | |||
| e5a9b9aa50 | |||
| c9b2d5bd8d | 
| @ -1 +1,17 @@ | |||||||
| src/web/static/* | .git | ||||||
|  | .githooks | ||||||
|  | .mypy_cache | ||||||
|  | .pytest_cache | ||||||
|  | .venv | ||||||
|  | .vscode | ||||||
|  | node_modules | ||||||
|  | static | ||||||
|  | .drone.yml | ||||||
|  | .editorconfig | ||||||
|  | .gitignore | ||||||
|  | Caddyfile | ||||||
|  | CHANGELOG.md | ||||||
|  | db.sqlite3 | ||||||
|  | docker-compose* | ||||||
|  | Dockerfile | ||||||
|  | Makefile | ||||||
|  | |||||||
							
								
								
									
										39
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						| @ -5,21 +5,52 @@ name: default | |||||||
|  |  | ||||||
| steps: | steps: | ||||||
| - name: test | - name: test | ||||||
|   image: python:3.10 |   image: python:3.12 | ||||||
|   commands: |   commands: | ||||||
|     - python -m pip install poetry |     - python -m pip install poetry | ||||||
|     - poetry install |     - poetry install | ||||||
|     - poetry env info |     - poetry env info | ||||||
|  |     - poetry run python manage.py migrate | ||||||
|     - poetry run pytest |     - poetry run pytest | ||||||
| - name: build container |  | ||||||
|  | - name: build-prod | ||||||
|   image: plugins/docker |   image: plugins/docker | ||||||
|   settings: |   settings: | ||||||
|     repo: registry.kucharczyk.xyz/timetracker |     repo: registry.kucharczyk.xyz/timetracker | ||||||
|     tags: |     tags: | ||||||
|       - latest |       - latest | ||||||
|  |       - 1.1.0 | ||||||
|  |   depends_on: | ||||||
|  |     - "test" | ||||||
|  |   when: | ||||||
|  |     branch: | ||||||
|  |       - main | ||||||
|  |  | ||||||
|  | - name: build-non-prod | ||||||
|  |   image: plugins/docker | ||||||
|  |   settings: | ||||||
|  |     repo: registry.kucharczyk.xyz/timetracker | ||||||
|  |     tags: | ||||||
|  |       - ${DRONE_COMMIT_REF} | ||||||
|  |       - ${DRONE_COMMIT_BRANCH} | ||||||
|  |   when: | ||||||
|  |     branch: | ||||||
|  |       exclude: | ||||||
|  |         - main | ||||||
|  |   depends_on: | ||||||
|  |     - "test" | ||||||
|  |  | ||||||
|  | - name: redeploy on portainer | ||||||
|  |   image: plugins/webhook | ||||||
|  |   settings: | ||||||
|  |     urls: | ||||||
|  |       from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL | ||||||
|  |   depends_on: | ||||||
|  |     - "build-prod" | ||||||
|  |      | ||||||
|  |      | ||||||
|  |      | ||||||
| trigger: | trigger: | ||||||
|   event: |   event: | ||||||
|   - push |   - push | ||||||
|   - cron |   - cron | ||||||
|   exclude: |  | ||||||
|     - pull_request |  | ||||||
							
								
								
									
										17
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,17 @@ | |||||||
|  | root = true | ||||||
|  |  | ||||||
|  | [*] | ||||||
|  | end_of_line = lf | ||||||
|  | insert_final_newline = true | ||||||
|  |  | ||||||
|  | [*.{js,py}] | ||||||
|  | charset = utf-8 | ||||||
|  |  | ||||||
|  | # 4 space indentation | ||||||
|  | [*.py] | ||||||
|  | indent_style = space | ||||||
|  | indent_size = 4 | ||||||
|  |  | ||||||
|  | [**/*.js] | ||||||
|  | indent_style = space | ||||||
|  | indent_size = 2 | ||||||
							
								
								
									
										0
									
								
								.githooks/pre-commit → .githooks/pre-commit.disabled
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
							
								
								
									
										36
									
								
								.github/workflows/build-docker.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,36 @@ | |||||||
|  | name: Django CI/CD | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   push: | ||||||
|  |     paths-ignore: [ 'README.md' ] | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   test: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - uses: actions/setup-python@v4 | ||||||
|  |         with: | ||||||
|  |           python-version: 3.12 | ||||||
|  |       - run: | | ||||||
|  |           python -m pip install poetry | ||||||
|  |           poetry install | ||||||
|  |           poetry env info | ||||||
|  |           poetry run python manage.py migrate | ||||||
|  |           # PROD=1 poetry run pytest | ||||||
|  |   build-and-push: | ||||||
|  |     needs: test | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     if: github.ref == 'refs/heads/main' | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - uses: docker/setup-buildx-action@v3 | ||||||
|  |       - uses: docker/build-push-action@v5 | ||||||
|  |         with: | ||||||
|  |           context: . | ||||||
|  |           push: true | ||||||
|  |           tags: | | ||||||
|  |             registry.kucharczyk.xyz/timetracker:latest | ||||||
|  |             registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}           | ||||||
|  |     env: | ||||||
|  |       VERSION_NUMBER: 1.5.1 | ||||||
							
								
								
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -1,8 +1,12 @@ | |||||||
| __pycache__ | __pycache__ | ||||||
| .mypy_cache | .mypy_cache | ||||||
| .pytest_cache | .pytest_cache | ||||||
| .venv | .venv/ | ||||||
| node_modules | node_modules | ||||||
| package-lock.json | package-lock.json | ||||||
| db.sqlite3 | db.sqlite3 | ||||||
| src/web/static | /static/ | ||||||
|  | dist/ | ||||||
|  | .DS_Store | ||||||
|  | .python-version | ||||||
|  | .direnv | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,15 @@ | |||||||
|  | repos: | ||||||
|  | -   repo: https://github.com/psf/black | ||||||
|  |     rev: 24.8.0 | ||||||
|  |     hooks: | ||||||
|  |     -   id: black | ||||||
|  | - repo: https://github.com/pycqa/isort | ||||||
|  |   rev: 5.13.2 | ||||||
|  |   hooks: | ||||||
|  |     - id: isort | ||||||
|  |       name: isort (python) | ||||||
|  | - repo: https://github.com/Riverside-Healthcare/djLint | ||||||
|  |   rev: v1.34.0 | ||||||
|  |   hooks: | ||||||
|  |     - id: djlint-reformat-django | ||||||
|  |     - id: djlint-django | ||||||
							
								
								
									
										11
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,11 @@ | |||||||
|  | { | ||||||
|  |     "recommendations": [ | ||||||
|  |         "charliermarsh.ruff", | ||||||
|  |         "ms-python.python", | ||||||
|  |         "ms-python.vscode-pylance", | ||||||
|  |         "ms-python.debugpy", | ||||||
|  |         "batisteo.vscode-django", | ||||||
|  |         "bradlc.vscode-tailwindcss", | ||||||
|  |         "EditorConfig.EditorConfig" | ||||||
|  |     ] | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -4,5 +4,30 @@ | |||||||
|     ], |     ], | ||||||
|     "python.testing.unittestEnabled": false, |     "python.testing.unittestEnabled": false, | ||||||
|     "python.testing.pytestEnabled": true, |     "python.testing.pytestEnabled": true, | ||||||
|     "python.analysis.typeCheckingMode": "basic" |     "python.analysis.typeCheckingMode": "strict", | ||||||
| } |     "[python]": { | ||||||
|  |         "editor.defaultFormatter": "charliermarsh.ruff", | ||||||
|  |         "editor.formatOnSave": true, | ||||||
|  |         "editor.codeActionsOnSave": { | ||||||
|  |             "source.fixAll": "explicit", | ||||||
|  |             "source.organizeImports": "explicit" | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
|  |     "ruff.path": ["/nix/store/s3q6qc2954x62bkcs9dwaxyiqchl7j01-ruff-0.5.6/bin/ruff"], | ||||||
|  |     "tailwind-fold.supportedLanguages": [ | ||||||
|  |         "html", | ||||||
|  |         "typescriptreact", | ||||||
|  |         "javascriptreact", | ||||||
|  |         "typescript", | ||||||
|  |         "javascript", | ||||||
|  |         "vue-html", | ||||||
|  |         "vue", | ||||||
|  |         "php", | ||||||
|  |         "markdown", | ||||||
|  |         "coffeescript", | ||||||
|  |         "svelte", | ||||||
|  |         "astro", | ||||||
|  |         "erb", | ||||||
|  |         "django-html" | ||||||
|  |     ] | ||||||
|  | } | ||||||
|  | |||||||
							
								
								
									
										233
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @ -1,3 +1,234 @@ | |||||||
|  | ## Unreleased | ||||||
|  |  | ||||||
|  | ## New | ||||||
|  | * Render notes as Markdown | ||||||
|  | * Require login by default | ||||||
|  | * Add stats for dropped purchases, monthly playtimes | ||||||
|  | * Allow deleting purchases | ||||||
|  | * Add all-time stats | ||||||
|  | * Manage purchases | ||||||
|  |  | ||||||
|  | ## Improved | ||||||
|  | * mark refunded purchases red on game overview | ||||||
|  | * increase session count on game overview when starting a new session | ||||||
|  | * game overview: | ||||||
|  |   * sort purchases also by date purchased (on top of date released) | ||||||
|  |   * improve header format, make it more appealing | ||||||
|  |   * ignore manual sessions when calculating session average | ||||||
|  | * stats: improve purchase name consistency | ||||||
|  | * session list: use display name instead of sort name | ||||||
|  | * unify the appearance of game links, and make them expand to full size on hover | ||||||
|  |  | ||||||
|  | ## Fixed | ||||||
|  | * Fix title not being displayed on the Recent sessions page | ||||||
|  | * Avoid errors when displaying game overview with zero sessions | ||||||
|  |  | ||||||
|  | ## 1.5.2 / 2024-01-14 21:27+01:00 | ||||||
|  |  | ||||||
|  | ## Improved | ||||||
|  | * game overview: | ||||||
|  |   * improve how editions and purchases are displayed | ||||||
|  |   * make it possible to end session from overview | ||||||
|  | * add purchase: only allow choosing purchases of selected edition | ||||||
|  | * session list: | ||||||
|  |   * starting and ending sessions is much faster/doest not reload the page | ||||||
|  |   * listing sessions is much faster | ||||||
|  |  | ||||||
|  | ## 1.5.1 / 2023-11-14 21:10+01:00 | ||||||
|  |  | ||||||
|  | ## Improved | ||||||
|  | * Disallow choosing non-game purchase as related purchase | ||||||
|  | * Improve display of purchases | ||||||
|  |  | ||||||
|  | ## 1.5.0 / 2023-11-14 19:27+01:00 | ||||||
|  |  | ||||||
|  | ## New | ||||||
|  | * Add stat for finished this year's games | ||||||
|  | * Add purchase types: | ||||||
|  |   * Game (previously all of them were this type) | ||||||
|  |   * DLC | ||||||
|  |   * Season Pass | ||||||
|  |   * Battle Pass | ||||||
|  |  | ||||||
|  | ## Fixed | ||||||
|  | * Order purchases by date on game view | ||||||
|  |  | ||||||
|  | ## 1.4.0 / 2023-11-09 21:01+01:00 | ||||||
|  |  | ||||||
|  | ### New | ||||||
|  | * More fields are now optional. This is to make it easier to add new items in bulk. | ||||||
|  |   * Game: Wikidata ID | ||||||
|  |   * Edition: Platform, Year | ||||||
|  |   * Purchase: Platform | ||||||
|  |   * Platform: Group | ||||||
|  |   * Session: Device | ||||||
|  | * New fields: | ||||||
|  |   * Game: Year Released | ||||||
|  |     * To record original year of release | ||||||
|  |     * Upon migration, this will be set to a year of any of the game's edition that has it set | ||||||
|  |   * Purchase: Date Finished | ||||||
|  | * Editions are now unique combination of name and platform | ||||||
|  | * Add more stats: | ||||||
|  |   * All finished games | ||||||
|  |   * All finished 2023 games | ||||||
|  |   * All finished games that were purchased this year | ||||||
|  |   * Sessions (count) | ||||||
|  |   * Days played | ||||||
|  |   * Finished (count) | ||||||
|  |   * Unfinished (count) | ||||||
|  |   * Refunded (count) | ||||||
|  |   * Backlog Decrease (count) | ||||||
|  | * New workflow: | ||||||
|  |   * Adding Game, Edition, Purchase, and Session in a row is now much faster | ||||||
|  |  | ||||||
|  | ### Improved | ||||||
|  | * game overview: simplify playtime range display | ||||||
|  | * new session: order devices alphabetically | ||||||
|  | * ignore English articles when sorting names | ||||||
|  |   * added a new sort_name field that gets automatically created | ||||||
|  | * automatically fill certain values in forms: | ||||||
|  |   * new game: name and sort name after typing | ||||||
|  |   * new edition: name, sort name, and year when selecting game | ||||||
|  |   * new purchase: platform when selecting edition | ||||||
|  |  | ||||||
|  | ## 1.3.0 / 2023-11-05 15:09+01:00 | ||||||
|  |  | ||||||
|  | ### New | ||||||
|  | * Add Stats to the main navigation | ||||||
|  | * Allow selecting year on the Stats page | ||||||
|  |  | ||||||
|  | ### Improved | ||||||
|  | * Make some pages redirect back instead to session list | ||||||
|  |  | ||||||
|  | ### Improved | ||||||
|  | * Make navigation more compact | ||||||
|  |  | ||||||
|  | ### Fixed | ||||||
|  | * Correctly limit sessions to a single year for stats | ||||||
|  |  | ||||||
|  | ## 1.2.0 / 2023-11-01 20:18+01:00 | ||||||
|  |  | ||||||
|  | ### New | ||||||
|  | * Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15) | ||||||
|  |  | ||||||
|  | ### Enhancements | ||||||
|  | * Add a button to start session from game overview | ||||||
|  |  | ||||||
|  | ## 1.1.2 / 2023-10-13 16:30+02:00 | ||||||
|  |  | ||||||
|  | ### Enhancements | ||||||
|  | * Durations are formatted in a consisent manner across all pages | ||||||
|  |  | ||||||
|  | ### Fixes | ||||||
|  | * Game Overview: display duration when >1 hour instead of displaying 0 | ||||||
|  |  | ||||||
|  | ## 1.1.1 / 2023-10-09 20:52+02:00 | ||||||
|  |  | ||||||
|  | ### New | ||||||
|  | * Add notes section to game overview | ||||||
|  |  | ||||||
|  | ### Enhancements | ||||||
|  | * Make it possible to add any data on the game overview page | ||||||
|  |  | ||||||
|  | ## 1.1.0 / 2023-10-09 00:01+02:00 | ||||||
|  |  | ||||||
|  | ### New | ||||||
|  | * Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8) | ||||||
|  | * Add helper buttons next to datime fields | ||||||
|  | * Add copy button on Add session page to copy times between fields | ||||||
|  | * Change fonts to IBM Plex | ||||||
|  |  | ||||||
|  | ### Enhancements | ||||||
|  | * Improve form appearance | ||||||
|  | * Focus important fields on forms | ||||||
|  | * Use the same form when editing a session as when adding a session | ||||||
|  | * Change recent session view to current year instead of last 30 days | ||||||
|  | * Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52) | ||||||
|  | * Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53) | ||||||
|  |  | ||||||
|  | ### Fixes | ||||||
|  |  | ||||||
|  | * Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58) | ||||||
|  | * Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ## 1.0.3 / 2023-02-20 17:16+01:00 | ||||||
|  |  | ||||||
|  | * Add wikidata ID and year for editions | ||||||
|  | * Add icons for game, edition, purchase filters | ||||||
|  | * Allow filtering by game, edition, purchase from the session list | ||||||
|  | * Allow editing filtered entities from session list | ||||||
|  |  | ||||||
|  | ## 1.0.2 / 2023-02-18 21:48+01:00 | ||||||
|  |  | ||||||
|  | * Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49) | ||||||
|  | * Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48) | ||||||
|  | * Add support for purchase prices | ||||||
|  | * Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28) | ||||||
|  |  | ||||||
|  | ## 1.0.1 / 2023-01-30 22:17+01:00 | ||||||
|  |  | ||||||
|  | * Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46) | ||||||
|  | * Show markers on smaller graphs to make it clearer which dates the session belong to | ||||||
|  | * Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47) | ||||||
|  |  | ||||||
|  | ## 1.0.0 / 2023-01-20 19:54+01:00 | ||||||
|  |  | ||||||
|  | * Breaking | ||||||
|  |   * Due to major re-arranging and re-naming of the folder structure, tables also had to be renamed. | ||||||
|  | * Fixed | ||||||
|  |   * Sort form fields alphabetically (https://git.kucharczyk.xyz/lukas/timetracker/issues/39, https://git.kucharczyk.xyz/lukas/timetracker/issues/40) | ||||||
|  |   * Start session button starts different game than it says (#44) | ||||||
|  |  | ||||||
|  | ## 0.2.5 / 2023-01-18 17:01+01:00 | ||||||
|  |  | ||||||
|  | * New | ||||||
|  |   * When adding session, pre-select game with the last session | ||||||
|  | * Fixed | ||||||
|  |   * Start session now button would take up 100% width, leading to accidental clicks (https://git.kucharczyk.xyz/lukas/timetracker/issues/37) | ||||||
|  | * Removed | ||||||
|  |   * Session model property `last` is already implemented by Django method `last()`, thus it was removed (https://git.kucharczyk.xyz/lukas/timetracker/issues/38) | ||||||
|  |  | ||||||
|  | ## 0.2.4 / 2023-01-16 19:39+01:00 | ||||||
|  |  | ||||||
|  | * Fixed | ||||||
|  |   * When filtering by game, the "Filtering by (...)" text would erroneously list an unrelated platform | ||||||
|  |   * Playtime graph would display timeline backwards | ||||||
|  |   * Playtime graph with many dates would overlap (https://git.kucharczyk.xyz/lukas/timetracker/issues/34) | ||||||
|  |   * Manually added times (= without end timestamp) would make graphs look ugly and noisy (https://git.kucharczyk.xyz/lukas/timetracker/issues/35) | ||||||
|  |  | ||||||
|  | ## 0.2.3 / 2023-01-15 23:13+01:00 | ||||||
|  |  | ||||||
|  | * Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32) | ||||||
|  | * Order session by newest as preparation for https://git.kucharczyk.xyz/lukas/timetracker/issues/33 | ||||||
|  |  | ||||||
|  | ## 0.2.2 / 2023-01-15 17:59+01:00 | ||||||
|  |  | ||||||
|  | * Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29) | ||||||
|  | * Fix error when showing session list with no sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/31) | ||||||
|  |  | ||||||
|  | ## 0.2.1 / 2023-01-13 16:53+01:00 | ||||||
|  |  | ||||||
|  | * List number of sessions when filtering on session list | ||||||
|  | * Start sessions of last purchase from list (https://git.kucharczyk.xyz/lukas/timetracker/issues/19) | ||||||
|  |  | ||||||
|  | ## 0.2.0 / 2023-01-09 22:42+01:00 | ||||||
|  |  | ||||||
|  | * Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6) | ||||||
|  | * Make formatting durations more robust, change default duration display to "X hours" (https://git.kucharczyk.xyz/lukas/timetracker/issues/26) | ||||||
|  |  | ||||||
|  | ## 0.1.4 / 2023-01-08 15:45+01:00 | ||||||
|  |  | ||||||
|  | * Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23) | ||||||
|  |  | ||||||
|  | ## 0.1.3 / 2023-01-08 15:23+01:00 | ||||||
|  |  | ||||||
|  | * Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22) | ||||||
|  |  | ||||||
|  | ## 0.1.2 / 2023-01-07 22:05+01:00 | ||||||
|  |  | ||||||
|  | * Switch to Uvicorn/Gunicorn + Caddy (https://git.kucharczyk.xyz/lukas/timetracker/pulls/4) | ||||||
|  |  | ||||||
| ## 0.1.1 / 2023-01-05 23:26+01:00 | ## 0.1.1 / 2023-01-05 23:26+01:00 | ||||||
| * Order by timestamp_start by default | * Order by timestamp_start by default | ||||||
| * Add pre-commit hook to update version | * Add pre-commit hook to update version | ||||||
| @ -19,4 +250,4 @@ | |||||||
| * Make it possible to add a new platform | * Make it possible to add a new platform | ||||||
| * Save calculated duration to database if both timestamps are set | * Save calculated duration to database if both timestamps are set | ||||||
| * Improve session listing | * Improve session listing | ||||||
| * Set version in the footer to fixed, fix main container height | * Set version in the footer to fixed, fix main container height | ||||||
|  | |||||||
| @ -5,10 +5,10 @@ | |||||||
|  |  | ||||||
| :8000 { | :8000 { | ||||||
|     handle_path /static/* { |     handle_path /static/* { | ||||||
|         root * src/web/static/ |         root * /usr/share/caddy | ||||||
|         file_server |         file_server | ||||||
|     } |     } | ||||||
|     handle { |     handle { | ||||||
|         reverse_proxy :8001 |         reverse_proxy backend:8001 | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										50
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| @ -1,31 +1,45 @@ | |||||||
| FROM node as css | FROM python:3.12.0-slim-bullseye | ||||||
| WORKDIR /app |  | ||||||
| COPY . /app |  | ||||||
| RUN npm install && \ |  | ||||||
|     npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify |  | ||||||
|  |  | ||||||
| FROM python:3.10.9-alpine | ENV VERSION_NUMBER=1.5.2 \ | ||||||
|  |     PROD=1 \ | ||||||
|  |     PYTHONUNBUFFERED=1 \ | ||||||
|  |     PYTHONFAULTHANDLER=1 \ | ||||||
|  |     PYTHONHASHSEED=random \ | ||||||
|  |     PYTHONDONTWRITEBYTECODE=1 \ | ||||||
|  |     PIP_NO_CACHE_DIR=1 \ | ||||||
|  |     PIP_DISABLE_PIP_VERSION_CHECK=1 \ | ||||||
|  |     PIP_DEFAULT_TIMEOUT=100 \ | ||||||
|  |     PIP_ROOT_USER_ACTION=ignore \ | ||||||
|  |     POETRY_NO_INTERACTION=1 \ | ||||||
|  |     POETRY_VIRTUALENVS_CREATE=false \ | ||||||
|  |     POETRY_CACHE_DIR='/var/cache/pypoetry' \ | ||||||
|  |     POETRY_HOME='/usr/local' | ||||||
|  |  | ||||||
| ENV VERSION_NUMBER 0.1.1-8-gf7b69f7 | RUN apt-get update && apt-get upgrade -y \ | ||||||
| ENV PROD 1 |   && apt-get install --no-install-recommends -y \ | ||||||
|  |  | ||||||
| RUN apk add \ |  | ||||||
|     bash \ |     bash \ | ||||||
|     vim \ |  | ||||||
|     curl \ |     curl \ | ||||||
|     caddy |   && curl -sSL 'https://install.python-poetry.org' | python - \ | ||||||
| RUN adduser -D -u 1000 timetracker |   && poetry --version \ | ||||||
|  |   && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ | ||||||
|  |   && apt-get clean -y && rm -rf /var/lib/apt/lists/* | ||||||
|  |  | ||||||
|  | RUN useradd -m --uid 1000 timetracker \ | ||||||
|  |     && mkdir -p '/var/www/django/static' \ | ||||||
|  |     && chown timetracker:timetracker '/var/www/django/static' | ||||||
| WORKDIR /home/timetracker/app | WORKDIR /home/timetracker/app | ||||||
| COPY . /home/timetracker/app/ | COPY . /home/timetracker/app/ | ||||||
| RUN chown -R timetracker:timetracker /home/timetracker/app | RUN chown -R timetracker:timetracker /home/timetracker/app | ||||||
| COPY --from=css /app/src/web/tracker/static/base.css /home/timetracker/app/src/web/tracker/static/base.css |  | ||||||
| COPY entrypoint.sh / | COPY entrypoint.sh / | ||||||
| RUN chmod +x /entrypoint.sh | RUN chmod +x /entrypoint.sh | ||||||
|  |  | ||||||
|  | RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \ | ||||||
|  |     echo "$PROD" \ | ||||||
|  |     && poetry version \ | ||||||
|  |     && poetry run pip install -U pip \ | ||||||
|  |     && poetry install --only main --no-interaction --no-ansi --sync | ||||||
|  |  | ||||||
| USER timetracker | USER timetracker | ||||||
| ENV PATH="$PATH:/home/timetracker/.local/bin" |  | ||||||
| RUN pip install --no-cache-dir poetry |  | ||||||
| RUN poetry install --without dev |  | ||||||
|  |  | ||||||
| EXPOSE 8000 | EXPOSE 8000 | ||||||
| ENTRYPOINT [ "/entrypoint.sh" ] | CMD [ "/entrypoint.sh" ] | ||||||
|  | |||||||
							
								
								
									
										58
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						| @ -2,49 +2,62 @@ all: css migrate | |||||||
|  |  | ||||||
| initialize: npm css migrate sethookdir loadplatforms | initialize: npm css migrate sethookdir loadplatforms | ||||||
|  |  | ||||||
| HTMLFILES := $(shell find src/web/tracker/templates -type f) | HTMLFILES := $(shell find games/templates -type f) | ||||||
|  | PYTHON_VERSION = 3.12 | ||||||
|  |  | ||||||
| npm: | npm: | ||||||
| 	npm install | 	npm install | ||||||
|  |  | ||||||
| css: src/input.css | css: common/input.css | ||||||
| 	npx tailwindcss -i ./src/input.css -o  ./src/web/tracker/static/base.css | 	npx tailwindcss -i ./common/input.css -o  ./games/static/base.css | ||||||
|  |  | ||||||
| css-dev: css |  | ||||||
| 	npx tailwindcss -i ./src/input.css -o  ./src/web/tracker/static/base.css --watch |  | ||||||
|  |  | ||||||
| makemigrations: | makemigrations: | ||||||
| 	poetry run python src/web/manage.py makemigrations | 	poetry run python manage.py makemigrations | ||||||
|  |  | ||||||
| migrate: makemigrations | migrate: makemigrations | ||||||
| 	poetry run python src/web/manage.py migrate | 	poetry run python manage.py migrate | ||||||
|  |  | ||||||
|  | init: | ||||||
|  | 	pyenv install -s $(PYTHON_VERSION) | ||||||
|  | 	pyenv local $(PYTHON_VERSION) | ||||||
|  | 	pip install poetry | ||||||
|  | 	poetry install | ||||||
|  | 	npm install | ||||||
|  |  | ||||||
|  | dev: | ||||||
|  | 	@npx concurrently \ | ||||||
|  | 		--names "Django,Tailwind" \ | ||||||
|  | 		--prefix-colors "blue,green" \ | ||||||
|  | 		"poetry run python -Wa manage.py runserver" \ | ||||||
|  | 		"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" | ||||||
|  |  | ||||||
| dev: migrate sethookdir |  | ||||||
| 	poetry run python src/web/manage.py runserver_plus |  | ||||||
|  |  | ||||||
| caddy: | caddy: | ||||||
| 	caddy run --watch | 	caddy run --watch | ||||||
|  |  | ||||||
| dev-prod: migrate collectstatic sethookdir | dev-prod: migrate collectstatic | ||||||
| 	cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker | 	PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker | ||||||
|  |  | ||||||
| dumptracker: | dumpgames: | ||||||
| 	poetry run python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml | 	poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml | ||||||
|  |  | ||||||
| loadplatforms: | loadplatforms: | ||||||
| 	poetry run python src/web/manage.py loaddata platforms.yaml | 	poetry run python manage.py loaddata platforms.yaml | ||||||
|  |  | ||||||
|  | loadall: | ||||||
|  | 	poetry run python manage.py loaddata data.yaml | ||||||
|  |  | ||||||
| loadsample: | loadsample: | ||||||
| 	poetry run python src/web/manage.py loaddata sample.yaml | 	poetry run python manage.py loaddata sample.yaml | ||||||
|  |  | ||||||
| createsuperuser: | createsuperuser: | ||||||
| 	poetry run python src/web/manage.py createsuperuser | 	poetry run python manage.py createsuperuser | ||||||
|  |  | ||||||
| shell: | shell: | ||||||
| 	poetry run python src/web/manage.py shell | 	poetry run python manage.py shell | ||||||
|  |  | ||||||
| collectstatic: | collectstatic: | ||||||
| 	poetry run python src/web/manage.py collectstatic | 	poetry run python manage.py collectstatic --clear --no-input | ||||||
|  |  | ||||||
| poetry.lock: pyproject.toml | poetry.lock: pyproject.toml | ||||||
| 	poetry install | 	poetry install | ||||||
| @ -52,13 +65,10 @@ poetry.lock: pyproject.toml | |||||||
| test: poetry.lock | test: poetry.lock | ||||||
| 	poetry run pytest | 	poetry run pytest | ||||||
|  |  | ||||||
| sethookdir: |  | ||||||
| 	git config core.hooksPath .githooks |  | ||||||
|  |  | ||||||
| date: | date: | ||||||
| 	python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' | 	poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' | ||||||
|  |  | ||||||
| cleanstatic: | cleanstatic: | ||||||
| 	rm -r src/web/static/* | 	rm -r static/* | ||||||
|  |  | ||||||
| clean: cleanstatic | clean: cleanstatic | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @ -1,3 +1,15 @@ | |||||||
| # Timetracker | # Timetracker | ||||||
|  |  | ||||||
| A simple game catalogue and play session tracker. | A simple game catalogue and play session tracker. | ||||||
|  |  | ||||||
|  | # Development | ||||||
|  |  | ||||||
|  | The project uses `pyenv` to manage installed Python versions. | ||||||
|  | If you have `pyenv` installed, you can simply run: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | make init | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`. | ||||||
|  | Afterwards, you can start the development server using `make dev`. | ||||||
							
								
								
									
										30
									
								
								common/import_data.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,30 @@ | |||||||
|  | import csv | ||||||
|  | from typing import TypeAlias | ||||||
|  |  | ||||||
|  | from games.models import Game | ||||||
|  |  | ||||||
|  | DataList: TypeAlias = list[dict[str, str]] | None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def read_csv(filename: str) -> DataList: | ||||||
|  |     with open(filename, "r") as csvfile: | ||||||
|  |         writer = csv.DictReader(csvfile) | ||||||
|  |         return writer | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def import_data(data: DataList): | ||||||
|  |     matching_names = {} | ||||||
|  |     for line in data: | ||||||
|  |         name = line["name"] | ||||||
|  |         if name not in matching_names: | ||||||
|  |             # try exact match first | ||||||
|  |             try: | ||||||
|  |                 game_id = Game.objects.get(name__iexact=name) | ||||||
|  |             except: | ||||||
|  |                 pass | ||||||
|  |             matching_names[name] = game_id | ||||||
|  |     print(f"Exact matched {len(matching_names)} games.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def import_from_file(filename: str): | ||||||
|  |     import_data(read_csv(filename)) | ||||||
							
								
								
									
										171
									
								
								common/input.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,171 @@ | |||||||
|  | @tailwind base; | ||||||
|  | @tailwind components; | ||||||
|  | @tailwind utilities; | ||||||
|  |  | ||||||
|  | @font-face { | ||||||
|  |   font-family: "IBM Plex Mono"; | ||||||
|  |   src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2"); | ||||||
|  |   font-weight: 400; | ||||||
|  |   font-style: normal; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @font-face { | ||||||
|  |   font-family: "IBM Plex Sans"; | ||||||
|  |   src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2"); | ||||||
|  |   font-weight: 400; | ||||||
|  |   font-style: normal; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @font-face { | ||||||
|  |   font-family: "IBM Plex Serif"; | ||||||
|  |   src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2"); | ||||||
|  |   font-weight: 400; | ||||||
|  |   font-style: normal; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @font-face { | ||||||
|  |   font-family: "IBM Plex Serif"; | ||||||
|  |   src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2"); | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-style: normal; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @font-face { | ||||||
|  |   font-family: "IBM Plex Sans Condensed"; | ||||||
|  |   src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2"); | ||||||
|  |   font-weight: 400; | ||||||
|  |   font-style: normal; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* a:hover { | ||||||
|  |   text-decoration-color: #ff4400; | ||||||
|  |   color: rgb(254, 185, 160); | ||||||
|  |   transition: all 0.2s ease-out; | ||||||
|  | } */ | ||||||
|  |  | ||||||
|  | form label { | ||||||
|  |   @apply dark:text-slate-400; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .responsive-table { | ||||||
|  |   @apply dark:text-white mx-auto table-fixed; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .responsive-table tr:nth-child(even) { | ||||||
|  |   @apply bg-slate-800 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .responsive-table tbody tr:nth-child(odd) { | ||||||
|  |   @apply bg-slate-900 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .responsive-table thead th { | ||||||
|  |   @apply text-left border-b-2 border-b-slate-500 text-xl; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .responsive-table thead th:not(:first-child), | ||||||
|  | .responsive-table td:not(:first-child) { | ||||||
|  |   @apply border-l border-l-slate-500; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @layer utilities { | ||||||
|  |   .min-w-20char { | ||||||
|  |     min-width: 20ch; | ||||||
|  |   } | ||||||
|  |   .max-w-20char { | ||||||
|  |     max-width: 20ch; | ||||||
|  |   } | ||||||
|  |   .min-w-30char { | ||||||
|  |     min-width: 30ch; | ||||||
|  |   } | ||||||
|  |   .max-w-30char { | ||||||
|  |     max-width: 30ch; | ||||||
|  |   } | ||||||
|  |   .max-w-35char { | ||||||
|  |     max-width: 35ch; | ||||||
|  |   } | ||||||
|  |   .max-w-40char { | ||||||
|  |     max-width: 40ch; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | form input, | ||||||
|  | select, | ||||||
|  | textarea { | ||||||
|  |   @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | form input:disabled, | ||||||
|  | select:disabled, | ||||||
|  | textarea:disabled { | ||||||
|  |   @apply dark:bg-slate-700 dark:text-slate-400; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .errorlist { | ||||||
|  |   @apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media screen and (min-width: 768px) { | ||||||
|  |   form input, | ||||||
|  |   select, | ||||||
|  |   textarea { | ||||||
|  |     width: 300px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media screen and (max-width: 768px) { | ||||||
|  |   form input, | ||||||
|  |   select, | ||||||
|  |   textarea { | ||||||
|  |     width: 150px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #button-container button { | ||||||
|  |   @apply mx-1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .basic-button-container { | ||||||
|  |   @apply flex space-x-2 justify-center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .basic-button { | ||||||
|  |   @apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markdown-content ul { | ||||||
|  |   list-style-type: disc; | ||||||
|  |   list-style-position: inside; | ||||||
|  |   padding-left: 1em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markdown-content ol { | ||||||
|  |   list-style-type: decimal; | ||||||
|  |   list-style-position: inside; | ||||||
|  |   padding-left: 1em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markdown-content ul,  | ||||||
|  | .markdown-content ol { | ||||||
|  |     list-style-position: outside; | ||||||
|  |     padding-left: 1em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markdown-content ul ul,  | ||||||
|  | .markdown-content ul ol,  | ||||||
|  | .markdown-content ol ul,  | ||||||
|  | .markdown-content ol ol { | ||||||
|  |     list-style-type: circle; | ||||||
|  |     margin-top: 0.5em; | ||||||
|  |     margin-bottom: 0.5em; | ||||||
|  |     padding-left: 1em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* .truncate-container { | ||||||
|  |   @apply inline-block relative; | ||||||
|  |   a { | ||||||
|  |     @apply inline-block truncate max-w-20char transition-all group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4; | ||||||
|  |      | ||||||
|  |   }   | ||||||
|  | } */ | ||||||
							
								
								
									
										72
									
								
								common/time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,72 @@ | |||||||
|  | import re | ||||||
|  | from datetime import timedelta | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _safe_timedelta(duration: timedelta | int | None): | ||||||
|  |     if duration == None: | ||||||
|  |         return timedelta(0) | ||||||
|  |     elif isinstance(duration, int): | ||||||
|  |         return timedelta(seconds=duration) | ||||||
|  |     elif isinstance(duration, timedelta): | ||||||
|  |         return duration | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_duration( | ||||||
|  |     duration: timedelta | int | float | None, format_string: str = "%H hours" | ||||||
|  | ) -> str: | ||||||
|  |     """ | ||||||
|  |     Format timedelta into the specified format_string. | ||||||
|  |     Valid format variables: | ||||||
|  |     - %H hours | ||||||
|  |     - %m minutes | ||||||
|  |     - %s seconds | ||||||
|  |     - %r total seconds | ||||||
|  |     Values don't change into higher units if those units are missing | ||||||
|  |     from the formatting string. For example: | ||||||
|  |     - 61 seconds as "%s" = 61 seconds | ||||||
|  |     - 61 seconds as "%m %s" = 1 minutes 1 seconds" | ||||||
|  |     Format specifiers can include width and precision options: | ||||||
|  |     - %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros) | ||||||
|  |     """ | ||||||
|  |     minute_seconds = 60 | ||||||
|  |     hour_seconds = 60 * minute_seconds | ||||||
|  |     day_seconds = 24 * hour_seconds | ||||||
|  |     safe_duration = _safe_timedelta(duration) | ||||||
|  |     # we don't need float | ||||||
|  |     seconds_total = int(safe_duration.total_seconds()) | ||||||
|  |     # timestamps where end is before start | ||||||
|  |     if seconds_total < 0: | ||||||
|  |         seconds_total = 0 | ||||||
|  |     days = hours = hours_float = minutes = seconds = 0 | ||||||
|  |     remainder = seconds = seconds_total | ||||||
|  |     if "%d" in format_string: | ||||||
|  |         days, remainder = divmod(seconds_total, day_seconds) | ||||||
|  |     if re.search(r"%\d*\.?\d*H", format_string): | ||||||
|  |         hours_float, remainder = divmod(remainder, hour_seconds) | ||||||
|  |         hours = float(hours_float) + remainder / hour_seconds | ||||||
|  |     if re.search(r"%\d*\.?\d*m", format_string): | ||||||
|  |         minutes, seconds = divmod(remainder, minute_seconds) | ||||||
|  |     literals = { | ||||||
|  |         "d": str(days), | ||||||
|  |         "H": str(hours) if "m" not in format_string else str(hours_float), | ||||||
|  |         "m": str(minutes), | ||||||
|  |         "s": str(seconds), | ||||||
|  |         "r": str(seconds_total), | ||||||
|  |     } | ||||||
|  |     formatted_string = format_string | ||||||
|  |     for pattern, replacement in literals.items(): | ||||||
|  |         # Match format specifiers with optional width and precision | ||||||
|  |         match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string) | ||||||
|  |         if match: | ||||||
|  |             format_spec = match.group(1) | ||||||
|  |             if "." in format_spec: | ||||||
|  |                 # Format the number as float if precision is specified | ||||||
|  |                 replacement = f"{float(replacement):{format_spec}f}" | ||||||
|  |             else: | ||||||
|  |                 # Format the number as integer if no precision is specified | ||||||
|  |                 replacement = f"{int(float(replacement)):>{format_spec}}" | ||||||
|  |             # Replace the format specifier with the formatted number | ||||||
|  |             formatted_string = re.sub( | ||||||
|  |                 rf"%\d*\.?\d*{pattern}", replacement, formatted_string | ||||||
|  |             ) | ||||||
|  |     return formatted_string | ||||||
							
								
								
									
										33
									
								
								common/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,33 @@ | |||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 | ||||||
							
								
								
									
										17
									
								
								docker-compose.no-caddy.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,17 @@ | |||||||
|  | --- | ||||||
|  | services: | ||||||
|  |   timetracker: | ||||||
|  |     image: registry.kucharczyk.xyz/timetracker | ||||||
|  |     build: | ||||||
|  |       context: . | ||||||
|  |       dockerfile: Dockerfile | ||||||
|  |     container_name: timetracker | ||||||
|  |     environment: | ||||||
|  |       - TZ=Europe/Prague | ||||||
|  |       - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" | ||||||
|  |     user: "1000" | ||||||
|  |     # volumes: | ||||||
|  |     #   - "db:/home/timetracker/app/src/timetracker/db.sqlite3" | ||||||
|  |     ports: | ||||||
|  |       - "8000:8000" | ||||||
|  |     restart: unless-stopped | ||||||
| @ -1,17 +1,30 @@ | |||||||
| --- | --- | ||||||
| services: | services: | ||||||
|   timetracker: |   backend: | ||||||
|     image: registry.kucharczyk.xyz/timetracker |     image: registry.kucharczyk.xyz/timetracker | ||||||
|     build: |     build: | ||||||
|       context: . |       context: . | ||||||
|       dockerfile: Dockerfile |       dockerfile: Dockerfile | ||||||
|     container_name: timetracker |  | ||||||
|     environment: |     environment: | ||||||
|       - TZ=Europe/Prague |       - TZ=Europe/Prague | ||||||
|       - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" |       - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" | ||||||
|     user: "1000" |     user: "1000" | ||||||
|     # volumes: |     volumes: | ||||||
|     #   - "db:/home/timetracker/app/src/web/db.sqlite3" |       - "static-files:/var/www/django/static" | ||||||
|  |       - "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3" | ||||||
|  |     restart: unless-stopped | ||||||
|  |  | ||||||
|  |   frontend: | ||||||
|  |     image: caddy | ||||||
|  |     volumes: | ||||||
|  |       - "static-files:/usr/share/caddy:ro" | ||||||
|  |       - "$PWD/Caddyfile:/etc/caddy/Caddyfile" | ||||||
|     ports: |     ports: | ||||||
|       - "8000:8000" |       - "8000:8000" | ||||||
|     restart: unless-stopped |     depends_on: | ||||||
|  |       - backend | ||||||
|  |  | ||||||
|  | volumes: | ||||||
|  |   static-files: | ||||||
|  |      | ||||||
|  |      | ||||||
| @ -2,12 +2,18 @@ | |||||||
| # Apply database migrations | # Apply database migrations | ||||||
| set -euo pipefail | set -euo pipefail | ||||||
| echo "Apply database migrations" | echo "Apply database migrations" | ||||||
| poetry run python src/web/manage.py migrate | poetry run python manage.py migrate | ||||||
|  |  | ||||||
| echo "Collect static files" | echo "Collect static files" | ||||||
| poetry run python src/web/manage.py collectstatic | poetry run python manage.py collectstatic --clear --no-input | ||||||
|  |  | ||||||
| echo "Starting server" | _term() { | ||||||
| caddy start |   echo "Caught SIGTERM signal!" | ||||||
| cd src/web || exit |   kill -SIGTERM "$gunicorn_pid" | ||||||
| poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker | } | ||||||
|  | trap _term SIGTERM | ||||||
|  |  | ||||||
|  | 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=$! | ||||||
|  |  | ||||||
|  | wait "$gunicorn_pid" | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| from django.contrib import admin | from django.contrib import admin | ||||||
| from .models import Game, Purchase, Platform, Session | 
 | ||||||
|  | from games.models import Device, Edition, Game, Platform, Purchase, Session | ||||||
| 
 | 
 | ||||||
| # Register your models here. | # Register your models here. | ||||||
| admin.site.register(Game) | admin.site.register(Game) | ||||||
| admin.site.register(Purchase) | admin.site.register(Purchase) | ||||||
| admin.site.register(Platform) | admin.site.register(Platform) | ||||||
| admin.site.register(Session) | admin.site.register(Session) | ||||||
|  | admin.site.register(Edition) | ||||||
|  | admin.site.register(Device) | ||||||
| @ -1,6 +1,6 @@ | |||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TrackerConfig(AppConfig): | class GamesConfig(AppConfig): | ||||||
|     default_auto_field = "django.db.models.BigAutoField" |     default_auto_field = "django.db.models.BigAutoField" | ||||||
|     name = "tracker" |     name = "games" | ||||||
							
								
								
									
										1091
									
								
								games/fixtures/data.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -1,28 +1,28 @@ | |||||||
| - model: tracker.Platform | - model: games.Platform | ||||||
|   fields: |   fields: | ||||||
|     name: Steam |     name: Steam | ||||||
|     group: PC |     group: PC | ||||||
| - model: tracker.Platform | - model: games.Platform | ||||||
|   fields: |   fields: | ||||||
|     name: Xbox Gamepass |     name: Xbox Gamepass | ||||||
|     group: PC |     group: PC | ||||||
| - model: tracker.Platform | - model: games.Platform | ||||||
|   fields: |   fields: | ||||||
|     name: Epic Games Store |     name: Epic Games Store | ||||||
|     group: PC |     group: PC | ||||||
| - model: tracker.Platform | - model: games.Platform | ||||||
|   fields: |   fields: | ||||||
|     name: Playstation 5 |     name: Playstation 5 | ||||||
|     group: Playstation |     group: Playstation | ||||||
| - model: tracker.Platform | - model: games.Platform | ||||||
|   fields: |   fields: | ||||||
|     name: Playstation 4 |     name: Playstation 4 | ||||||
|     group: Playstation |     group: Playstation | ||||||
| - model: tracker.Platform | - model: games.Platform | ||||||
|   fields: |   fields: | ||||||
|     name: Nintendo Switch |     name: Nintendo Switch | ||||||
|     group: Nintendo |     group: Nintendo | ||||||
| - model: tracker.Platform | - model: games.Platform | ||||||
|   fields: |   fields: | ||||||
|     name: Nintendo 3DS |     name: Nintendo 3DS | ||||||
|     group: Nintendo |     group: Nintendo | ||||||
							
								
								
									
										225
									
								
								games/fixtures/ps-tracker.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,225 @@ | |||||||
|  | name,platform,start,end | ||||||
|  | Nioh 2,PS5,2022-12-17 19:34,2022-12-17 22:53 | ||||||
|  | Nioh 2,PS5,2022-12-15 02:25,2022-12-15 03:57 | ||||||
|  | Nioh 2,PS5,2022-12-13 02:41,2022-12-13 04:34 | ||||||
|  | Nioh 2,PS5,2022-12-11 21:01,2022-12-11 23:21 | ||||||
|  | VALKYRIE ELYSIUM,PS5,2022-12-11 06:07,2022-12-11 06:20 | ||||||
|  | Metal: Hellsinger,PS5,2022-12-11 05:50,2022-12-11 06:07 | ||||||
|  | Nioh 2,PS5,2022-12-11 04:31,2022-12-11 05:50 | ||||||
|  | Nioh 2,PS5,2022-12-11 04:11,2022-12-11 04:26 | ||||||
|  | Forspoken,PS5,2022-12-10 22:29,2022-12-10 23:10 | ||||||
|  | Nioh 2,PS5,2022-12-10 19:44,2022-12-10 22:29 | ||||||
|  | Nioh 2,PS5,2022-12-09 02:14,2022-12-09 04:16 | ||||||
|  | Nioh 2,PS5,2022-12-08 01:03,2022-12-08 01:57 | ||||||
|  | Nioh 2,PS5,2022-12-07 00:43,2022-12-07 04:16 | ||||||
|  | Nioh 2,PS5,2022-12-04 20:48,2022-12-04 23:31 | ||||||
|  | Nioh 2,PS5,2022-12-04 04:26,2022-12-04 07:01 | ||||||
|  | Nioh 2,PS5,2022-12-04 04:20,2022-12-04 04:22 | ||||||
|  | Nioh 2,PS5,2022-11-26 19:18,2022-11-26 21:28 | ||||||
|  | Nioh 2,PS5,2022-11-26 19:16,2022-11-26 19:18 | ||||||
|  | Nioh 2,PS5,2022-11-26 02:46,2022-11-26 03:56 | ||||||
|  | Nioh 2,PS5,2022-11-26 02:01,2022-11-26 02:43 | ||||||
|  | God of War Ragnarök,PS5,2022-11-24 23:03,2022-11-25 01:32 | ||||||
|  | God of War Ragnarök,PS5,2022-11-23 00:41,2022-11-23 07:52 | ||||||
|  | God of War Ragnarök,PS5,2022-11-21 22:52,2022-11-22 04:51 | ||||||
|  | God of War Ragnarök,PS5,2022-11-21 02:11,2022-11-21 05:13 | ||||||
|  | God of War Ragnarök,PS5,2022-11-20 21:34,2022-11-20 22:50 | ||||||
|  | God of War Ragnarök,PS5,2022-11-20 03:46,2022-11-20 05:52 | ||||||
|  | God of War Ragnarök,PS5,2022-11-19 14:30,2022-11-19 16:14 | ||||||
|  | God of War Ragnarök,PS5,2022-11-18 23:15,2022-11-19 04:16 | ||||||
|  | God of War Ragnarök,PS5,2022-11-18 19:58,2022-11-18 20:40 | ||||||
|  | God of War Ragnarök,PS5,2022-11-18 03:50,2022-11-18 06:25 | ||||||
|  | God of War Ragnarök,PS5,2022-11-17 19:36,2022-11-18 00:26 | ||||||
|  | God of War Ragnarök,PS5,2022-11-17 13:16,2022-11-17 16:13 | ||||||
|  | God of War Ragnarök,PS5,2022-11-16 21:45,2022-11-16 22:38 | ||||||
|  | God of War Ragnarök,PS5,2022-11-16 00:14,2022-11-16 04:28 | ||||||
|  | God of War Ragnarök,PS5,2022-11-15 01:33,2022-11-15 05:06 | ||||||
|  | God of War Ragnarök,PS5,2022-11-14 00:37,2022-11-14 04:43 | ||||||
|  | God of War Ragnarök,PS5,2022-11-12 23:32,2022-11-13 03:45 | ||||||
|  | God of War Ragnarök,PS5,2022-11-12 03:17,2022-11-12 05:00 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-10-03 02:01,2022-10-03 02:23 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-10-02 13:59,2022-10-02 15:54 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-30 22:40,2022-10-01 02:50 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 22:38,2022-09-28 00:16 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 19:27,2022-09-27 21:09 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-26 20:58,2022-09-26 23:38 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 23:56,2022-09-26 02:36 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 14:57,2022-09-25 16:38 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 02:04,2022-09-25 02:12 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-09-23 20:33,2022-09-23 23:38 | ||||||
|  | Wo Long: Fallen Dynasty,PS5,2022-09-18 15:26,2022-09-18 16:58 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-08-31 00:42,2022-08-31 01:15 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 13:43,2022-08-18 15:12 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 00:42,2022-08-18 01:58 | ||||||
|  | Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-18 00:40,2022-08-18 00:42 | ||||||
|  | Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-14 15:46,2022-08-14 16:10 | ||||||
|  | FINAL FANTASY VII,PS5,2022-07-26 18:56,2022-07-26 20:22 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-26 17:39,2022-07-26 18:53 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-25 22:12,2022-07-26 04:37 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-24 00:09,2022-07-24 05:33 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-23 23:34,2022-07-23 23:48 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-23 18:05,2022-07-23 19:44 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-22 17:07,2022-07-23 01:48 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-22 15:26,2022-07-22 15:59 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-21 21:27,2022-07-21 22:43 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-21 20:48,2022-07-21 20:58 | ||||||
|  | FINAL FANTASY VII REMAKE,PS5,2022-07-21 18:35,2022-07-21 18:36 | ||||||
|  | Stray,PS5,2022-07-21 01:24,2022-07-21 02:34 | ||||||
|  | Stray,PS5,2022-07-19 23:49,2022-07-20 03:24 | ||||||
|  | Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-04 03:26,2022-07-04 03:34 | ||||||
|  | Red Dead Redemption,PS3,2022-07-04 02:36,2022-07-04 03:14 | ||||||
|  | Ghost of Tsushima,PS5,2022-07-04 01:38,2022-07-04 02:34 | ||||||
|  | Dark Cloud™,PS4,2022-07-01 23:48,2022-07-02 00:04 | ||||||
|  | Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-01 23:20,2022-07-01 23:46 | ||||||
|  | Resident Evil Director’s Cut,PS5,2022-07-01 23:14,2022-07-01 23:20 | ||||||
|  | ELEX II,PS5,2022-07-01 22:48,2022-07-01 23:13 | ||||||
|  | OlliOlli World,PS5,2022-07-01 21:30,2022-07-01 22:30 | ||||||
|  | Deep Rock Galactic,PS5,2022-06-16 05:30,2022-06-16 06:14 | ||||||
|  | Curse of the Dead Gods,PS5,2022-06-16 05:00,2022-06-16 05:22 | ||||||
|  | Persona 5: Dancing in Starlight,PS5,2022-04-29 20:14,2022-04-29 20:15 | ||||||
|  | Persona 5: Dancing in Starlight,PS5,2022-04-29 00:18,2022-04-29 00:44 | ||||||
|  | Dying Light 2,PS5,2022-04-14 01:26,2022-04-14 01:27 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-03-24 16:26,2022-03-24 16:27 | ||||||
|  | Grand Theft Auto V (PlayStation®5),PS5,2022-03-21 15:52,2022-03-21 15:59 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-23 19:37,2022-02-24 00:24 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-23 13:57,2022-02-23 17:44 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-22 18:05,2022-02-23 05:26 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-22 15:39,2022-02-22 17:02 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-22 00:05,2022-02-22 04:08 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-20 15:39,2022-02-20 23:08 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-20 14:54,2022-02-20 15:09 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-19 23:37,2022-02-20 04:45 | ||||||
|  | Horizon Forbidden West,PS5,2022-02-18 23:15,2022-02-19 03:27 | ||||||
|  | Assassin's Creed® Origins,PS5,2022-02-18 21:49,2022-02-18 23:15 | ||||||
|  | Assassin's Creed® Origins,PS5,2022-01-17 02:38,2022-01-17 02:50 | ||||||
|  | Deep Rock Galactic,PS5,2022-01-17 00:57,2022-01-17 02:35 | ||||||
|  | HITMAN 3,PS5,2021-11-17 00:35,2021-11-17 01:17 | ||||||
|  | HITMAN 3,PS5,2021-11-08 01:59,2021-11-08 06:17 | ||||||
|  | HITMAN 3,PS5,2021-11-07 03:10,2021-11-07 05:23 | ||||||
|  | HITMAN 3,PS5,2021-11-06 04:23,2021-11-06 08:49 | ||||||
|  | HITMAN 3,PS5,2021-11-06 02:17,2021-11-06 03:31 | ||||||
|  | HITMAN 3,PS5,2021-11-05 21:33,2021-11-05 23:24 | ||||||
|  | HITMAN 3,PS5,2021-11-05 03:09,2021-11-05 03:34 | ||||||
|  | HITMAN 3,PS5,2021-11-05 00:47,2021-11-05 02:26 | ||||||
|  | HITMAN 3,PS5,2021-11-04 20:27,2021-11-04 23:32 | ||||||
|  | HITMAN 3,PS5,2021-11-04 01:34,2021-11-04 05:33 | ||||||
|  | RESIDENT EVIL 3,PS5,2021-11-03 23:14,2021-11-03 23:56 | ||||||
|  | RESIDENT EVIL 3,PS5,2021-11-02 23:56,2021-11-03 05:10 | ||||||
|  | RESIDENT EVIL 3,PS5,2021-11-02 21:22,2021-11-02 23:23 | ||||||
|  | RESIDENT EVIL 3,PS5,2021-11-02 05:36,2021-11-02 06:56 | ||||||
|  | HITMAN 3,PS5,2021-11-02 03:00,2021-11-02 05:36 | ||||||
|  | HITMAN 3,PS5,2021-11-02 01:19,2021-11-02 01:25 | ||||||
|  | HITMAN™ 2,PS5,2021-11-02 01:09,2021-11-02 01:19 | ||||||
|  | HITMAN 3,PS5,2021-11-01 23:45,2021-11-02 01:09 | ||||||
|  | RESIDENT EVIL 3,PS5,2021-11-01 19:32,2021-11-01 19:47 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-17 01:06,2021-10-17 03:27 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-16 20:58,2021-10-16 22:00 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-05 02:30,2021-10-05 03:27 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 23:12,2021-10-04 01:21 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 03:02,2021-10-03 04:42 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 20:12,2021-10-02 21:10 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 01:40,2021-10-02 03:36 | ||||||
|  | Marvel's Spider-Man: Miles Morales,PS5,2021-10-01 04:34,2021-10-01 05:30 | ||||||
|  | DEATHLOOP,PS5,2021-10-01 01:12,2021-10-01 04:27 | ||||||
|  | DEATHLOOP,PS5,2021-09-30 03:04,2021-09-30 06:30 | ||||||
|  | DEATHLOOP,PS5,2021-09-29 00:28,2021-09-29 05:08 | ||||||
|  | Persona 5 Royal,PS5,2021-09-28 00:36,2021-09-28 03:08 | ||||||
|  | Persona 5 Royal,PS5,2021-09-27 02:16,2021-09-27 05:56 | ||||||
|  | Persona 5 Royal,PS5,2021-09-26 14:54,2021-09-26 16:32 | ||||||
|  | Persona 5 Royal,PS5,2021-09-25 18:43,2021-09-25 23:26 | ||||||
|  | Persona 5 Royal,PS5,2021-09-24 21:41,2021-09-25 03:40 | ||||||
|  | Persona 5 Royal,PS5,2021-09-23 00:18,2021-09-23 06:26 | ||||||
|  | Persona 5 Royal,PS5,2021-09-21 20:27,2021-09-22 05:43 | ||||||
|  | Persona 5 Royal,PS5,2021-09-21 01:07,2021-09-21 06:06 | ||||||
|  | Borderlands: The Handsome Collection,PS5,2021-09-20 23:59,2021-09-21 01:07 | ||||||
|  | Persona 5 Royal,PS5,2021-09-20 23:53,2021-09-20 23:59 | ||||||
|  | DEATHLOOP,PS5,2021-09-20 02:03,2021-09-20 06:29 | ||||||
|  | DEATHLOOP,PS5,2021-09-19 19:49,2021-09-20 01:16 | ||||||
|  | Borderlands: The Handsome Collection,PS5,2021-09-19 00:51,2021-09-19 03:41 | ||||||
|  | Borderlands: The Handsome Collection,PS5,2021-09-17 23:45,2021-09-18 01:48 | ||||||
|  | Borderlands: The Handsome Collection,PS5,2021-09-17 23:40,2021-09-17 23:41 | ||||||
|  | DEATHLOOP,PS5,2021-09-17 16:48,2021-09-17 18:56 | ||||||
|  | DEATHLOOP,PS5,2021-09-17 03:02,2021-09-17 04:39 | ||||||
|  | DEATHLOOP,PS5,2021-09-17 00:03,2021-09-17 02:53 | ||||||
|  | DEATHLOOP,PS5,2021-09-16 18:39,2021-09-16 21:12 | ||||||
|  | Persona 5 Royal,PS5,2021-09-16 18:29,2021-09-16 18:30 | ||||||
|  | Persona 5 Royal,PS5,2021-09-16 02:26,2021-09-16 06:13 | ||||||
|  | Persona 5 Royal,PS5,2021-09-16 02:20,2021-09-16 02:21 | ||||||
|  | Persona 5 Royal,PS5,2021-09-15 01:48,2021-09-15 06:07 | ||||||
|  | Persona 5 Royal,PS5,2021-09-14 22:21,2021-09-15 01:22 | ||||||
|  | Persona 5 Royal,PS5,2021-09-14 02:01,2021-09-14 05:48 | ||||||
|  | Persona 5 Royal,PS5,2021-09-14 00:24,2021-09-14 01:46 | ||||||
|  | Persona 5 Royal,PS5,2021-08-12 05:04,2021-08-12 07:05 | ||||||
|  | Persona 5 Royal,PS5,2021-08-11 05:02,2021-08-11 06:48 | ||||||
|  | Persona 5 Royal,PS5,2021-08-09 00:37,2021-08-09 06:15 | ||||||
|  | Persona 5 Royal,PS5,2021-08-08 00:31,2021-08-08 08:01 | ||||||
|  | Persona 5 Royal,PS5,2021-08-07 19:51,2021-08-07 22:50 | ||||||
|  | Persona 5 Royal,PS5,2021-08-06 23:51,2021-08-07 01:35 | ||||||
|  | Persona 5 Royal,PS5,2021-08-06 19:26,2021-08-06 22:26 | ||||||
|  | Persona 5 Royal,PS5,2021-08-06 02:42,2021-08-06 06:51 | ||||||
|  | Persona 5 Royal,PS5,2021-08-06 00:37,2021-08-06 01:54 | ||||||
|  | Far Cry® 5,PS5,2021-08-01 23:27,2021-08-02 02:09 | ||||||
|  | Far Cry® 5,PS5,2021-08-01 18:10,2021-08-01 19:40 | ||||||
|  | STAR WARS™: Squadrons,PS5,2021-08-01 18:02,2021-08-01 18:10 | ||||||
|  | STAR WARS™: Squadrons,PS5,2021-08-01 00:24,2021-08-01 00:30 | ||||||
|  | STEEP,PS5,2021-08-01 00:15,2021-08-01 00:24 | ||||||
|  | Red Dead Redemption 2,PS5,2021-07-31 23:48,2021-08-01 00:13 | ||||||
|  | Persona 5 Royal,PS5,2021-07-30 19:09,2021-07-30 19:10 | ||||||
|  | Persona 5 Royal,PS5,2021-07-29 03:41,2021-07-29 04:59 | ||||||
|  | Persona 5 Royal,PS5,2021-07-28 02:32,2021-07-28 03:07 | ||||||
|  | Demon's Souls,PS5,2021-07-28 00:12,2021-07-28 02:32 | ||||||
|  | Red Dead Redemption 2,PS5,2021-07-27 23:20,2021-07-27 23:23 | ||||||
|  | Red Dead Redemption 2,PS5,2021-07-26 00:42,2021-07-26 01:13 | ||||||
|  | Ghost of Tsushima,PS5,2021-07-25 19:03,2021-07-25 22:12 | ||||||
|  | Ghost of Tsushima,PS5,2021-07-25 18:52,2021-07-25 18:55 | ||||||
|  | Rez Infinite,PS5,2021-07-25 18:32,2021-07-25 18:52 | ||||||
|  | Returnal,PS5,2021-07-25 05:24,2021-07-25 05:26 | ||||||
|  | Tom Clancy's The Division® 2,PS5,2021-07-25 02:12,2021-07-25 05:24 | ||||||
|  | Returnal,PS5,2021-07-25 00:00,2021-07-25 02:12 | ||||||
|  | Returnal,PS5,2021-07-24 16:13,2021-07-24 17:39 | ||||||
|  | Returnal,PS5,2021-07-24 03:02,2021-07-24 07:02 | ||||||
|  | Returnal,PS5,2021-07-23 18:08,2021-07-23 20:39 | ||||||
|  | Returnal,PS5,2021-07-23 14:36,2021-07-23 15:38 | ||||||
|  | Titanfall™ 2,PS5,2021-07-22 02:42,2021-07-22 03:20 | ||||||
|  | Returnal,PS5,2021-07-20 02:12,2021-07-20 05:40 | ||||||
|  | Returnal,PS5,2021-07-19 03:37,2021-07-19 05:24 | ||||||
|  | Concrete Genie,PS5,2021-07-19 03:35,2021-07-19 03:37 | ||||||
|  | Concrete Genie,PS5,2021-07-18 05:04,2021-07-18 05:30 | ||||||
|  | Stranded Deep,PS5,2021-07-18 04:32,2021-07-18 04:58 | ||||||
|  | Sniper Elite 4,PS5,2021-07-18 04:16,2021-07-18 04:32 | ||||||
|  | Oddworld: Soulstorm,PS5,2021-07-18 04:00,2021-07-18 04:14 | ||||||
|  | Zombie Army 4: Dead War,PS5,2021-07-18 03:48,2021-07-18 03:58 | ||||||
|  | Sekiro™: Shadows Die Twice,PS5,2021-07-18 03:00,2021-07-18 03:48 | ||||||
|  | Returnal,PS5,2021-07-17 17:41,2021-07-17 23:32 | ||||||
|  | Returnal,PS5,2021-07-17 01:35,2021-07-17 06:07 | ||||||
|  | Returnal,PS5,2021-07-17 00:23,2021-07-17 01:21 | ||||||
|  | Another World - 20th Anniversary Edition,PS5,2021-07-15 22:09,2021-07-15 23:33 | ||||||
|  | Sekiro™: Shadows Die Twice,PS5,2021-07-15 21:52,2021-07-15 22:09 | ||||||
|  | Sekiro™: Shadows Die Twice,PS5,2021-07-15 19:07,2021-07-15 20:13 | ||||||
|  | Sekiro™: Shadows Die Twice,PS5,2021-07-15 01:50,2021-07-15 03:31 | ||||||
|  | Sekiro™: Shadows Die Twice,PS5,2021-07-15 00:12,2021-07-15 01:14 | ||||||
|  | Sekiro™: Shadows Die Twice,PS5,2021-07-14 03:19,2021-07-14 05:13 | ||||||
|  | Persona 5,PS5,2021-07-13 21:56,2021-07-13 21:58 | ||||||
|  | Persona 5,PS5,2021-07-13 01:32,2021-07-13 02:59 | ||||||
|  | Maquette,PS5,2021-07-13 01:30,2021-07-13 01:32 | ||||||
|  | Maquette,PS5,2021-07-12 23:59,2021-07-13 00:34 | ||||||
|  | ASTRO's PLAYROOM,PS5,2021-07-12 23:06,2021-07-12 23:59 | ||||||
|  | Crash Bandicoot N. Sane Trilogy,PS5,2021-07-12 23:01,2021-07-12 23:06 | ||||||
|  | Virtua Fighter 5 Ultimate Showdown,PS4,2021-07-02 19:46,2021-07-02 20:57 | ||||||
|  | Bloodborne™,PS4,2021-04-03 19:50,2021-04-03 23:52 | ||||||
|  | Tom Clancy's The Division® 2,PS4,2021-04-03 02:10,2021-04-03 05:22 | ||||||
|  | Bloodborne™,PS4,2021-04-02 21:34,2021-04-03 02:10 | ||||||
|  | Bloodborne™,PS4,2021-04-02 06:01,2021-04-02 08:11 | ||||||
|  | Tom Clancy's The Division® 2,PS4,2021-04-02 04:24,2021-04-02 06:01 | ||||||
|  | Tom Clancy's The Division® 2,PS4,2021-03-31 02:55,2021-03-31 05:50 | ||||||
|  | Tom Clancy's The Division® 2,PS4,2021-03-29 02:00,2021-03-29 02:02 | ||||||
|  | Tom Clancy's The Division® 2,PS4,2021-03-27 03:31,2021-03-27 06:29 | ||||||
|  | Tom Clancy's The Division® 2,PS4,2021-03-26 05:10,2021-03-26 06:03 | ||||||
|  | Remnant: From the Ashes,PS4,2021-03-23 03:20,2021-03-23 05:58 | ||||||
|  | DARK SOULS™ II: Scholar of the First Sin,PS4,2021-03-20 01:31,2021-03-20 01:34 | ||||||
|  | Remnant: From the Ashes,PS4,2021-03-12 01:29,2021-03-12 01:30 | ||||||
|  | Remnant: From the Ashes,PS4,2021-03-08 02:41,2021-03-08 05:38 | ||||||
|  | Remnant: From the Ashes,PS4,2021-03-07 03:21,2021-03-07 06:49 | ||||||
|  | 13 Sentinels: Aegis Rim,PS4,2021-03-07 03:20,2021-03-07 03:21 | ||||||
|  | DARK SOULS™ II: Scholar of the First Sin,PS4,2020-10-24 23:43,2020-10-25 01:18 | ||||||
|  | Ghost of Tsushima,PS4,2020-10-24 23:14,2020-10-24 23:22 | ||||||
| 
 | 
							
								
								
									
										71
									
								
								games/fixtures/sample.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,71 @@ | |||||||
|  | - model: games.game | ||||||
|  |   pk: 1 | ||||||
|  |   fields: | ||||||
|  |     name: Nioh 2 | ||||||
|  |     wikidata: Q67482292 | ||||||
|  | - model: games.game | ||||||
|  |   pk: 2 | ||||||
|  |   fields: | ||||||
|  |     name: Elden Ring | ||||||
|  |     wikidata: Q64826862 | ||||||
|  | - model: games.game | ||||||
|  |   pk: 3 | ||||||
|  |   fields: | ||||||
|  |     name: Cyberpunk 2077 | ||||||
|  |     wikidata: Q3182559 | ||||||
|  | - model: games.purchase | ||||||
|  |   pk: 1 | ||||||
|  |   fields: | ||||||
|  |     game: 1 | ||||||
|  |     platform: 1 | ||||||
|  |     date_purchased: 2021-02-13 | ||||||
|  |     date_refunded: null | ||||||
|  | - model: games.purchase | ||||||
|  |   pk: 2 | ||||||
|  |   fields: | ||||||
|  |     game: 2 | ||||||
|  |     platform: 1 | ||||||
|  |     date_purchased: 2022-02-24 | ||||||
|  |     date_refunded: null | ||||||
|  | - model: games.purchase | ||||||
|  |   pk: 3 | ||||||
|  |   fields: | ||||||
|  |     game: 3 | ||||||
|  |     platform: 1 | ||||||
|  |     date_purchased: 2020-12-07 | ||||||
|  |     date_refunded: null | ||||||
|  | - model: games.platform | ||||||
|  |   pk: 1 | ||||||
|  |   fields: | ||||||
|  |     name: Steam | ||||||
|  |     group: PC | ||||||
|  | - model: games.platform | ||||||
|  |   pk: 3 | ||||||
|  |   fields: | ||||||
|  |     name: Xbox Gamepass | ||||||
|  |     group: PC | ||||||
|  | - model: games.platform | ||||||
|  |   pk: 4 | ||||||
|  |   fields: | ||||||
|  |     name: Epic Games Store | ||||||
|  |     group: PC | ||||||
|  | - model: games.platform | ||||||
|  |   pk: 5 | ||||||
|  |   fields: | ||||||
|  |     name: Playstation 5 | ||||||
|  |     group: Playstation | ||||||
|  | - model: games.platform | ||||||
|  |   pk: 6 | ||||||
|  |   fields: | ||||||
|  |     name: Playstation 4 | ||||||
|  |     group: Playstation | ||||||
|  | - model: games.platform | ||||||
|  |   pk: 7 | ||||||
|  |   fields: | ||||||
|  |     name: Nintendo Switch | ||||||
|  |     group: Nintendo | ||||||
|  | - model: games.platform | ||||||
|  |   pk: 8 | ||||||
|  |   fields: | ||||||
|  |     name: Nintendo 3DS | ||||||
|  |     group: Nintendo | ||||||
							
								
								
									
										175
									
								
								games/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,175 @@ | |||||||
|  | from django import forms | ||||||
|  | from django.urls import reverse | ||||||
|  |  | ||||||
|  | from common.utils import safe_getattr | ||||||
|  | from games.models import Device, Edition, Game, Platform, Purchase, Session | ||||||
|  |  | ||||||
|  | custom_date_widget = forms.DateInput(attrs={"type": "date"}) | ||||||
|  | custom_datetime_widget = forms.DateTimeInput( | ||||||
|  |     attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M" | ||||||
|  | ) | ||||||
|  | autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SessionForm(forms.ModelForm): | ||||||
|  |     # purchase = forms.ModelChoiceField( | ||||||
|  |     #     queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name") | ||||||
|  |     # ) | ||||||
|  |     purchase = forms.ModelChoiceField( | ||||||
|  |         queryset=Purchase.objects.order_by("edition__sort_name"), | ||||||
|  |         widget=forms.Select(attrs={"autofocus": "autofocus"}), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     device = forms.ModelChoiceField(queryset=Device.objects.order_by("name")) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         widgets = { | ||||||
|  |             "timestamp_start": custom_datetime_widget, | ||||||
|  |             "timestamp_end": custom_datetime_widget, | ||||||
|  |         } | ||||||
|  |         model = Session | ||||||
|  |         fields = [ | ||||||
|  |             "purchase", | ||||||
|  |             "timestamp_start", | ||||||
|  |             "timestamp_end", | ||||||
|  |             "duration_manual", | ||||||
|  |             "device", | ||||||
|  |             "note", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EditionChoiceField(forms.ModelChoiceField): | ||||||
|  |     def label_from_instance(self, obj) -> str: | ||||||
|  |         return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IncludePlatformSelect(forms.Select): | ||||||
|  |     def create_option(self, name, value, *args, **kwargs): | ||||||
|  |         option = super().create_option(name, value, *args, **kwargs) | ||||||
|  |         if platform_id := safe_getattr(value, "instance.platform.id"): | ||||||
|  |             option["attrs"]["data-platform"] = platform_id | ||||||
|  |         return option | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PurchaseForm(forms.ModelForm): | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|  |         # Automatically update related_purchase <select/> | ||||||
|  |         # to only include purchases of the selected edition. | ||||||
|  |         related_purchase_by_edition_url = reverse("related_purchase_by_edition") | ||||||
|  |         self.fields["edition"].widget.attrs.update( | ||||||
|  |             { | ||||||
|  |                 "hx-trigger": "load, click", | ||||||
|  |                 "hx-get": related_purchase_by_edition_url, | ||||||
|  |                 "hx-target": "#id_related_purchase", | ||||||
|  |                 "hx-swap": "outerHTML", | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     edition = EditionChoiceField( | ||||||
|  |         queryset=Edition.objects.order_by("sort_name"), | ||||||
|  |         widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), | ||||||
|  |     ) | ||||||
|  |     platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) | ||||||
|  |     related_purchase = forms.ModelChoiceField( | ||||||
|  |         queryset=Purchase.objects.filter(type=Purchase.GAME).order_by( | ||||||
|  |             "edition__sort_name" | ||||||
|  |         ), | ||||||
|  |         required=False, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         widgets = { | ||||||
|  |             "date_purchased": custom_date_widget, | ||||||
|  |             "date_refunded": custom_date_widget, | ||||||
|  |             "date_finished": custom_date_widget, | ||||||
|  |             "date_dropped": custom_date_widget, | ||||||
|  |         } | ||||||
|  |         model = Purchase | ||||||
|  |         fields = [ | ||||||
|  |             "edition", | ||||||
|  |             "platform", | ||||||
|  |             "date_purchased", | ||||||
|  |             "date_refunded", | ||||||
|  |             "date_finished", | ||||||
|  |             "date_dropped", | ||||||
|  |             "infinite", | ||||||
|  |             "price", | ||||||
|  |             "price_currency", | ||||||
|  |             "ownership_type", | ||||||
|  |             "type", | ||||||
|  |             "related_purchase", | ||||||
|  |             "name", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |     def clean(self): | ||||||
|  |         cleaned_data = super().clean() | ||||||
|  |         purchase_type = cleaned_data.get("type") | ||||||
|  |         related_purchase = cleaned_data.get("related_purchase") | ||||||
|  |         name = cleaned_data.get("name") | ||||||
|  |  | ||||||
|  |         # Set the type on the instance to use get_type_display() | ||||||
|  |         # This is safe because we're not saving the instance. | ||||||
|  |         self.instance.type = purchase_type | ||||||
|  |  | ||||||
|  |         if purchase_type != Purchase.GAME: | ||||||
|  |             type_display = self.instance.get_type_display() | ||||||
|  |             if not related_purchase: | ||||||
|  |                 self.add_error( | ||||||
|  |                     "related_purchase", | ||||||
|  |                     f"{type_display} must have a related purchase.", | ||||||
|  |                 ) | ||||||
|  |             if not name: | ||||||
|  |                 self.add_error("name", f"{type_display} must have a name.") | ||||||
|  |         return cleaned_data | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IncludeNameSelect(forms.Select): | ||||||
|  |     def create_option(self, name, value, *args, **kwargs): | ||||||
|  |         option = super().create_option(name, value, *args, **kwargs) | ||||||
|  |         if value: | ||||||
|  |             option["attrs"]["data-name"] = value.instance.name | ||||||
|  |             option["attrs"]["data-year"] = value.instance.year_released | ||||||
|  |         return option | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GameModelChoiceField(forms.ModelChoiceField): | ||||||
|  |     def label_from_instance(self, obj): | ||||||
|  |         # Use sort_name as the label for the option | ||||||
|  |         return obj.sort_name | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EditionForm(forms.ModelForm): | ||||||
|  |     game = GameModelChoiceField( | ||||||
|  |         queryset=Game.objects.order_by("sort_name"), | ||||||
|  |         widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}), | ||||||
|  |     ) | ||||||
|  |     platform = forms.ModelChoiceField( | ||||||
|  |         queryset=Platform.objects.order_by("name"), required=False | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = Edition | ||||||
|  |         fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GameForm(forms.ModelForm): | ||||||
|  |     class Meta: | ||||||
|  |         model = Game | ||||||
|  |         fields = ["name", "sort_name", "year_released", "wikidata"] | ||||||
|  |         widgets = {"name": autofocus_input_widget} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PlatformForm(forms.ModelForm): | ||||||
|  |     class Meta: | ||||||
|  |         model = Platform | ||||||
|  |         fields = ["name", "group"] | ||||||
|  |         widgets = {"name": autofocus_input_widget} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DeviceForm(forms.ModelForm): | ||||||
|  |     class Meta: | ||||||
|  |         model = Device | ||||||
|  |         fields = ["name", "type"] | ||||||
|  |         widgets = {"name": autofocus_input_widget} | ||||||
							
								
								
									
										1
									
								
								games/graphql/mutations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1 @@ | |||||||
|  | from .game import Mutation as GameMutation | ||||||
							
								
								
									
										29
									
								
								games/graphql/mutations/game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,29 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.types import Game | ||||||
|  | from games.models import Game as GameModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UpdateGameMutation(graphene.Mutation): | ||||||
|  |     class Arguments: | ||||||
|  |         id = graphene.ID(required=True) | ||||||
|  |         name = graphene.String() | ||||||
|  |         year_released = graphene.Int() | ||||||
|  |         wikidata = graphene.String() | ||||||
|  |  | ||||||
|  |     game = graphene.Field(Game) | ||||||
|  |  | ||||||
|  |     def mutate(self, info, id, name=None, year_released=None, wikidata=None): | ||||||
|  |         game_instance = GameModel.objects.get(pk=id) | ||||||
|  |         if name is not None: | ||||||
|  |             game_instance.name = name | ||||||
|  |         if year_released is not None: | ||||||
|  |             game_instance.year_released = year_released | ||||||
|  |         if wikidata is not None: | ||||||
|  |             game_instance.wikidata = wikidata | ||||||
|  |         game_instance.save() | ||||||
|  |         return UpdateGameMutation(game=game_instance) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Mutation(graphene.ObjectType): | ||||||
|  |     update_game = UpdateGameMutation.Field() | ||||||
							
								
								
									
										6
									
								
								games/graphql/queries/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,6 @@ | |||||||
|  | from .device import Query as DeviceQuery | ||||||
|  | from .edition import Query as EditionQuery | ||||||
|  | from .game import Query as GameQuery | ||||||
|  | from .platform import Query as PlatformQuery | ||||||
|  | from .purchase import Query as PurchaseQuery | ||||||
|  | from .session import Query as SessionQuery | ||||||
							
								
								
									
										11
									
								
								games/graphql/queries/device.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,11 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.types import Device | ||||||
|  | from games.models import Device as DeviceModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Query(graphene.ObjectType): | ||||||
|  |     devices = graphene.List(Device) | ||||||
|  |  | ||||||
|  |     def resolve_devices(self, info, **kwargs): | ||||||
|  |         return DeviceModel.objects.all() | ||||||
							
								
								
									
										11
									
								
								games/graphql/queries/edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,11 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.types import Edition | ||||||
|  | from games.models import Game as EditionModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Query(graphene.ObjectType): | ||||||
|  |     editions = graphene.List(Edition) | ||||||
|  |  | ||||||
|  |     def resolve_editions(self, info, **kwargs): | ||||||
|  |         return EditionModel.objects.all() | ||||||
							
								
								
									
										18
									
								
								games/graphql/queries/game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.types import Game | ||||||
|  | from games.models import Game as GameModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Query(graphene.ObjectType): | ||||||
|  |     games = graphene.List(Game) | ||||||
|  |     game_by_name = graphene.Field(Game, name=graphene.String(required=True)) | ||||||
|  |  | ||||||
|  |     def resolve_games(self, info, **kwargs): | ||||||
|  |         return GameModel.objects.all() | ||||||
|  |  | ||||||
|  |     def resolve_game_by_name(self, info, name): | ||||||
|  |         try: | ||||||
|  |             return GameModel.objects.get(name=name) | ||||||
|  |         except GameModel.DoesNotExist: | ||||||
|  |             return None | ||||||
							
								
								
									
										11
									
								
								games/graphql/queries/platform.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,11 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.types import Platform | ||||||
|  | from games.models import Platform as PlatformModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Query(graphene.ObjectType): | ||||||
|  |     platforms = graphene.List(Platform) | ||||||
|  |  | ||||||
|  |     def resolve_platforms(self, info, **kwargs): | ||||||
|  |         return PlatformModel.objects.all() | ||||||
							
								
								
									
										11
									
								
								games/graphql/queries/purchase.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,11 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.types import Purchase | ||||||
|  | from games.models import Purchase as PurchaseModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Query(graphene.ObjectType): | ||||||
|  |     purchases = graphene.List(Purchase) | ||||||
|  |  | ||||||
|  |     def resolve_purchases(self, info, **kwargs): | ||||||
|  |         return PurchaseModel.objects.all() | ||||||
							
								
								
									
										11
									
								
								games/graphql/queries/session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,11 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.types import Session | ||||||
|  | from games.models import Session as SessionModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Query(graphene.ObjectType): | ||||||
|  |     sessions = graphene.List(Session) | ||||||
|  |  | ||||||
|  |     def resolve_sessions(self, info, **kwargs): | ||||||
|  |         return SessionModel.objects.all() | ||||||
							
								
								
									
										44
									
								
								games/graphql/types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,44 @@ | |||||||
|  | from graphene_django import DjangoObjectType | ||||||
|  |  | ||||||
|  | from games.models import Device as DeviceModel | ||||||
|  | from games.models import Edition as EditionModel | ||||||
|  | from games.models import Game as GameModel | ||||||
|  | from games.models import Platform as PlatformModel | ||||||
|  | from games.models import Purchase as PurchaseModel | ||||||
|  | from games.models import Session as SessionModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Game(DjangoObjectType): | ||||||
|  |     class Meta: | ||||||
|  |         model = GameModel | ||||||
|  |         fields = "__all__" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Edition(DjangoObjectType): | ||||||
|  |     class Meta: | ||||||
|  |         model = EditionModel | ||||||
|  |         fields = "__all__" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Purchase(DjangoObjectType): | ||||||
|  |     class Meta: | ||||||
|  |         model = PurchaseModel | ||||||
|  |         fields = "__all__" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Session(DjangoObjectType): | ||||||
|  |     class Meta: | ||||||
|  |         model = SessionModel | ||||||
|  |         fields = "__all__" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Platform(DjangoObjectType): | ||||||
|  |     class Meta: | ||||||
|  |         model = PlatformModel | ||||||
|  |         fields = "__all__" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Device(DjangoObjectType): | ||||||
|  |     class Meta: | ||||||
|  |         model = DeviceModel | ||||||
|  |         fields = "__all__" | ||||||
| @ -1,7 +1,7 @@ | |||||||
| # Generated by Django 4.1.4 on 2023-01-02 18:27 | # Generated by Django 4.1.4 on 2023-01-02 18:27 | ||||||
| 
 | 
 | ||||||
| from django.db import migrations, models |  | ||||||
| import django.db.models.deletion | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| @ -60,14 +60,14 @@ class Migration(migrations.Migration): | |||||||
|                 ( |                 ( | ||||||
|                     "game", |                     "game", | ||||||
|                     models.ForeignKey( |                     models.ForeignKey( | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, to="tracker.game" |                         on_delete=django.db.models.deletion.CASCADE, to="games.game" | ||||||
|                     ), |                     ), | ||||||
|                 ), |                 ), | ||||||
|                 ( |                 ( | ||||||
|                     "platform", |                     "platform", | ||||||
|                     models.ForeignKey( |                     models.ForeignKey( | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|                         to="tracker.platform", |                         to="games.platform", | ||||||
|                     ), |                     ), | ||||||
|                 ), |                 ), | ||||||
|             ], |             ], | ||||||
| @ -93,7 +93,7 @@ class Migration(migrations.Migration): | |||||||
|                     "purchase", |                     "purchase", | ||||||
|                     models.ForeignKey( |                     models.ForeignKey( | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|                         to="tracker.purchase", |                         to="games.purchase", | ||||||
|                     ), |                     ), | ||||||
|                 ), |                 ), | ||||||
|             ], |             ], | ||||||
| @ -1,13 +1,14 @@ | |||||||
| # Generated by Django 4.1.4 on 2023-01-02 18:55 | # Generated by Django 4.1.4 on 2023-01-02 18:55 | ||||||
| 
 | 
 | ||||||
| import datetime | import datetime | ||||||
|  | 
 | ||||||
| from django.db import migrations, models | from django.db import migrations, models | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("tracker", "0001_initial"), |         ("games", "0001_initial"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
| @ -6,7 +6,7 @@ from django.db import migrations, models | |||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("tracker", "0002_alter_session_duration_manual"), |         ("games", "0002_alter_session_duration_manual"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
							
								
								
									
										22
									
								
								games/migrations/0004_alter_session_duration_manual.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,22 @@ | |||||||
|  | # 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 | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										35
									
								
								games/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,35 @@ | |||||||
|  | # 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, | ||||||
|  |         ) | ||||||
|  |     ] | ||||||
							
								
								
									
										35
									
								
								games/migrations/0006_auto_20230109_1904.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,35 @@ | |||||||
|  | # 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, | ||||||
|  |         ) | ||||||
|  |     ] | ||||||
| @ -0,0 +1,35 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-01-19 18:30 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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" | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										41
									
								
								games/migrations/0008_edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,41 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-02-18 16:29 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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" | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										34
									
								
								games/migrations/0009_create_editions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,34 @@ | |||||||
|  | # 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)] | ||||||
							
								
								
									
										21
									
								
								games/migrations/0010_alter_purchase_game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,21 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-02-18 19:06 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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" | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										18
									
								
								games/migrations/0011_rename_game_purchase_edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | |||||||
|  | # 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,23 @@ | |||||||
|  | # 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), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										31
									
								
								games/migrations/0013_purchase_ownership_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,31 @@ | |||||||
|  | # 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, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										52
									
								
								games/migrations/0014_device_session_device.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,52 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-02-18 19:59 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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,23 @@ | |||||||
|  | # 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,51 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 11:10 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0015_edition_wikidata_edition_year_released"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="edition", | ||||||
|  |             name="platform", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="games.platform", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="edition", | ||||||
|  |             name="year_released", | ||||||
|  |             field=models.IntegerField(blank=True, default=None, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="game", | ||||||
|  |             name="wikidata", | ||||||
|  |             field=models.CharField(blank=True, default=None, max_length=50, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="platform", | ||||||
|  |             name="group", | ||||||
|  |             field=models.CharField(blank=True, default=None, max_length=255, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="session", | ||||||
|  |             name="device", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="games.device", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,141 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 18:14 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def rename_duplicates(apps, schema_editor): | ||||||
|  |     Edition = apps.get_model("games", "Edition") | ||||||
|  |  | ||||||
|  |     duplicates = ( | ||||||
|  |         Edition.objects.values("name", "platform") | ||||||
|  |         .annotate(name_count=models.Count("id")) | ||||||
|  |         .filter(name_count__gt=1) | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     for duplicate in duplicates: | ||||||
|  |         counter = 1 | ||||||
|  |         duplicate_editions = Edition.objects.filter( | ||||||
|  |             name=duplicate["name"], platform_id=duplicate["platform"] | ||||||
|  |         ).order_by("id") | ||||||
|  |  | ||||||
|  |         for edition in duplicate_editions[1:]:  # Skip the first one | ||||||
|  |             edition.name = f"{edition.name} {counter}" | ||||||
|  |             edition.save() | ||||||
|  |             counter += 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def update_game_year(apps, schema_editor): | ||||||
|  |     Game = apps.get_model("games", "Game") | ||||||
|  |     Edition = apps.get_model("games", "Edition") | ||||||
|  |  | ||||||
|  |     for game in Game.objects.filter(year__isnull=True): | ||||||
|  |         # Try to get the first related edition with a non-null year_released | ||||||
|  |         edition = Edition.objects.filter(game=game, year_released__isnull=False).first() | ||||||
|  |         if edition: | ||||||
|  |             # If an edition is found, update the game's year | ||||||
|  |             game.year = edition.year_released | ||||||
|  |             game.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |     replaces = [ | ||||||
|  |         ("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"), | ||||||
|  |         ("games", "0017_alter_device_type_alter_purchase_platform"), | ||||||
|  |         ("games", "0018_auto_20231106_1825"), | ||||||
|  |         ("games", "0019_alter_edition_unique_together"), | ||||||
|  |         ("games", "0020_game_year"), | ||||||
|  |         ("games", "0021_auto_20231106_1909"), | ||||||
|  |         ("games", "0022_rename_year_game_year_released"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0015_edition_wikidata_edition_year_released"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="edition", | ||||||
|  |             name="platform", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="games.platform", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="edition", | ||||||
|  |             name="year_released", | ||||||
|  |             field=models.IntegerField(blank=True, default=None, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="game", | ||||||
|  |             name="wikidata", | ||||||
|  |             field=models.CharField(blank=True, default=None, max_length=50, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="platform", | ||||||
|  |             name="group", | ||||||
|  |             field=models.CharField(blank=True, default=None, max_length=255, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="session", | ||||||
|  |             name="device", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="games.device", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="device", | ||||||
|  |             name="type", | ||||||
|  |             field=models.CharField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("pc", "PC"), | ||||||
|  |                     ("co", "Console"), | ||||||
|  |                     ("ha", "Handheld"), | ||||||
|  |                     ("mo", "Mobile"), | ||||||
|  |                     ("sbc", "Single-board computer"), | ||||||
|  |                     ("un", "Unknown"), | ||||||
|  |                 ], | ||||||
|  |                 default="un", | ||||||
|  |                 max_length=3, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="platform", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="games.platform", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython( | ||||||
|  |             code=rename_duplicates, | ||||||
|  |         ), | ||||||
|  |         migrations.AlterUniqueTogether( | ||||||
|  |             name="edition", | ||||||
|  |             unique_together={("name", "platform")}, | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="game", | ||||||
|  |             name="year", | ||||||
|  |             field=models.IntegerField(blank=True, default=None, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython( | ||||||
|  |             code=update_game_year, | ||||||
|  |         ), | ||||||
|  |         migrations.RenameField( | ||||||
|  |             model_name="game", | ||||||
|  |             old_name="year", | ||||||
|  |             new_name="year_released", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,41 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 16:53 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="device", | ||||||
|  |             name="type", | ||||||
|  |             field=models.CharField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("pc", "PC"), | ||||||
|  |                     ("co", "Console"), | ||||||
|  |                     ("ha", "Handheld"), | ||||||
|  |                     ("mo", "Mobile"), | ||||||
|  |                     ("sbc", "Single-board computer"), | ||||||
|  |                     ("un", "Unknown"), | ||||||
|  |                 ], | ||||||
|  |                 default="un", | ||||||
|  |                 max_length=3, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="platform", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 to="games.platform", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										34
									
								
								games/migrations/0018_auto_20231106_1825.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,34 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 17:25 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def rename_duplicates(apps, schema_editor): | ||||||
|  |     Edition = apps.get_model("games", "Edition") | ||||||
|  |  | ||||||
|  |     duplicates = ( | ||||||
|  |         Edition.objects.values("name", "platform") | ||||||
|  |         .annotate(name_count=models.Count("id")) | ||||||
|  |         .filter(name_count__gt=1) | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     for duplicate in duplicates: | ||||||
|  |         counter = 1 | ||||||
|  |         duplicate_editions = Edition.objects.filter( | ||||||
|  |             name=duplicate["name"], platform_id=duplicate["platform"] | ||||||
|  |         ).order_by("id") | ||||||
|  |  | ||||||
|  |         for edition in duplicate_editions[1:]:  # Skip the first one | ||||||
|  |             edition.name = f"{edition.name} {counter}" | ||||||
|  |             edition.save() | ||||||
|  |             counter += 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0017_alter_device_type_alter_purchase_platform"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.RunPython(rename_duplicates), | ||||||
|  |     ] | ||||||
							
								
								
									
										17
									
								
								games/migrations/0019_alter_edition_unique_together.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,17 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 17:26 | ||||||
|  |  | ||||||
|  | from django.db import migrations | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0018_auto_20231106_1825"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterUniqueTogether( | ||||||
|  |             name="edition", | ||||||
|  |             unique_together={("name", "platform")}, | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										18
									
								
								games/migrations/0020_game_year.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 18:05 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0019_alter_edition_unique_together"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="game", | ||||||
|  |             name="year", | ||||||
|  |             field=models.IntegerField(blank=True, default=None, null=True), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										24
									
								
								games/migrations/0021_auto_20231106_1909.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,24 @@ | |||||||
|  | from django.db import migrations | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def update_game_year(apps, schema_editor): | ||||||
|  |     Game = apps.get_model("games", "Game") | ||||||
|  |     Edition = apps.get_model("games", "Edition") | ||||||
|  |  | ||||||
|  |     for game in Game.objects.filter(year__isnull=True): | ||||||
|  |         # Try to get the first related edition with a non-null year_released | ||||||
|  |         edition = Edition.objects.filter(game=game, year_released__isnull=False).first() | ||||||
|  |         if edition: | ||||||
|  |             # If an edition is found, update the game's year | ||||||
|  |             game.year = edition.year_released | ||||||
|  |             game.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0020_game_year"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.RunPython(update_game_year), | ||||||
|  |     ] | ||||||
							
								
								
									
										18
									
								
								games/migrations/0022_rename_year_game_year_released.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 18:12 | ||||||
|  |  | ||||||
|  | from django.db import migrations | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0021_auto_20231106_1909"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.RenameField( | ||||||
|  |             model_name="game", | ||||||
|  |             old_name="year", | ||||||
|  |             new_name="year_released", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										21
									
								
								games/migrations/0023_purchase_date_finished.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,21 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-06 18:24 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ( | ||||||
|  |             "games", | ||||||
|  |             "0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="date_finished", | ||||||
|  |             field=models.DateField(blank=True, null=True), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										39
									
								
								games/migrations/0024_edition_sort_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,39 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-09 09:32 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def create_sort_name(apps, schema_editor): | ||||||
|  |     Edition = apps.get_model( | ||||||
|  |         "games", "Edition" | ||||||
|  |     )  # Replace 'your_app_name' with the actual name of your app | ||||||
|  |  | ||||||
|  |     for edition in Edition.objects.all(): | ||||||
|  |         name = edition.name | ||||||
|  |         # Check for articles at the beginning of the name and move them to the end | ||||||
|  |         if name.lower().startswith("the "): | ||||||
|  |             sort_name = f"{name[4:]}, The" | ||||||
|  |         elif name.lower().startswith("a "): | ||||||
|  |             sort_name = f"{name[2:]}, A" | ||||||
|  |         elif name.lower().startswith("an "): | ||||||
|  |             sort_name = f"{name[3:]}, An" | ||||||
|  |         else: | ||||||
|  |             sort_name = name | ||||||
|  |         # Save the sort_name back to the database | ||||||
|  |         edition.sort_name = sort_name | ||||||
|  |         edition.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0023_purchase_date_finished"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="edition", | ||||||
|  |             name="sort_name", | ||||||
|  |             field=models.CharField(blank=True, default=None, max_length=255, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython(create_sort_name), | ||||||
|  |     ] | ||||||
							
								
								
									
										39
									
								
								games/migrations/0025_game_sort_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,39 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-09 09:32 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def create_sort_name(apps, schema_editor): | ||||||
|  |     Game = apps.get_model( | ||||||
|  |         "games", "Game" | ||||||
|  |     )  # Replace 'your_app_name' with the actual name of your app | ||||||
|  |  | ||||||
|  |     for game in Game.objects.all(): | ||||||
|  |         name = game.name | ||||||
|  |         # Check for articles at the beginning of the name and move them to the end | ||||||
|  |         if name.lower().startswith("the "): | ||||||
|  |             sort_name = f"{name[4:]}, The" | ||||||
|  |         elif name.lower().startswith("a "): | ||||||
|  |             sort_name = f"{name[2:]}, A" | ||||||
|  |         elif name.lower().startswith("an "): | ||||||
|  |             sort_name = f"{name[3:]}, An" | ||||||
|  |         else: | ||||||
|  |             sort_name = name | ||||||
|  |         # Save the sort_name back to the database | ||||||
|  |         game.sort_name = sort_name | ||||||
|  |         game.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0024_edition_sort_name"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="game", | ||||||
|  |             name="sort_name", | ||||||
|  |             field=models.CharField(blank=True, default=None, max_length=255, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython(create_sort_name), | ||||||
|  |     ] | ||||||
							
								
								
									
										27
									
								
								games/migrations/0026_purchase_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,27 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-14 08:35 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0025_game_sort_name"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="type", | ||||||
|  |             field=models.CharField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("game", "Game"), | ||||||
|  |                     ("dlc", "DLC"), | ||||||
|  |                     ("season_pass", "Season Pass"), | ||||||
|  |                     ("battle_pass", "Battle Pass"), | ||||||
|  |                 ], | ||||||
|  |                 default="game", | ||||||
|  |                 max_length=255, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										25
									
								
								games/migrations/0027_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,25 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-14 08:41 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0026_purchase_type"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="related_purchase", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  |                 to="games.purchase", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										26
									
								
								games/migrations/0028_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,26 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-14 11:05 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  | from games.models import Purchase | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def null_game_name(apps, schema_editor): | ||||||
|  |     Purchase.objects.filter(type=Purchase.GAME).update(name=None) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0027_purchase_related_purchase"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="name", | ||||||
|  |             field=models.CharField( | ||||||
|  |                 blank=True, default="Unknown Name", max_length=255, null=True | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython(null_game_name), | ||||||
|  |     ] | ||||||
							
								
								
									
										26
									
								
								games/migrations/0029_alter_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,26 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-14 21:19 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0028_purchase_name"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="related_purchase", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  |                 related_name="related_purchases", | ||||||
|  |                 to="games.purchase", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										18
									
								
								games/migrations/0030_alter_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-15 12:04 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0029_alter_purchase_related_purchase"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="name", | ||||||
|  |             field=models.CharField(blank=True, default="", max_length=255, null=True), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,44 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-15 13:51 | ||||||
|  |  | ||||||
|  | import django.utils.timezone | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0030_alter_purchase_name"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="device", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="edition", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="game", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="platform", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="session", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,52 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-15 18:02 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterModelOptions( | ||||||
|  |             name="session", | ||||||
|  |             options={"get_latest_by": "timestamp_start"}, | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="session", | ||||||
|  |             name="modified_at", | ||||||
|  |             field=models.DateTimeField(auto_now=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="device", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(auto_now_add=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="edition", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(auto_now_add=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="game", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(auto_now_add=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="platform", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(auto_now_add=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(auto_now_add=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="session", | ||||||
|  |             name="created_at", | ||||||
|  |             field=models.DateTimeField(auto_now_add=True), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										17
									
								
								games/migrations/0033_alter_edition_unique_together.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,17 @@ | |||||||
|  | # Generated by Django 4.2.7 on 2023-11-28 13:43 | ||||||
|  |  | ||||||
|  | from django.db import migrations | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0032_alter_session_options_session_modified_at_and_more"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterUniqueTogether( | ||||||
|  |             name="edition", | ||||||
|  |             unique_together={("name", "platform", "year_released")}, | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,23 @@ | |||||||
|  | # Generated by Django 4.2.7 on 2024-01-03 21:27 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0033_alter_edition_unique_together"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="date_dropped", | ||||||
|  |             field=models.DateField(blank=True, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="infinite", | ||||||
|  |             field=models.BooleanField(default=False), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										263
									
								
								games/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,263 @@ | |||||||
|  | from datetime import timedelta | ||||||
|  |  | ||||||
|  | from django.core.exceptions import ValidationError | ||||||
|  | from django.db import models | ||||||
|  | from django.db.models import F, Sum | ||||||
|  | from django.utils import timezone | ||||||
|  |  | ||||||
|  | from common.time import format_duration | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Game(models.Model): | ||||||
|  |     name = models.CharField(max_length=255) | ||||||
|  |     sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||||
|  |     year_released = models.IntegerField(null=True, blank=True, default=None) | ||||||
|  |     wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) | ||||||
|  |     created_at = models.DateTimeField(auto_now_add=True) | ||||||
|  |  | ||||||
|  |     session_average: float | int | timedelta | None | ||||||
|  |     session_count: int | None | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return self.name | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Platform(models.Model): | ||||||
|  |     name = models.CharField(max_length=255) | ||||||
|  |     group = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||||
|  |     created_at = models.DateTimeField(auto_now_add=True) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return self.name | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Edition(models.Model): | ||||||
|  |     class Meta: | ||||||
|  |         unique_together = [["name", "platform", "year_released"]] | ||||||
|  |  | ||||||
|  |     game = models.ForeignKey(Game, on_delete=models.CASCADE) | ||||||
|  |     name = models.CharField(max_length=255) | ||||||
|  |     sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||||
|  |     platform = models.ForeignKey( | ||||||
|  |         Platform, on_delete=models.CASCADE, null=True, blank=True, default=None | ||||||
|  |     ) | ||||||
|  |     year_released = models.IntegerField(null=True, blank=True, default=None) | ||||||
|  |     wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) | ||||||
|  |     created_at = models.DateTimeField(auto_now_add=True) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return self.sort_name | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PurchaseQueryset(models.QuerySet): | ||||||
|  |     def refunded(self): | ||||||
|  |         return self.filter(date_refunded__isnull=False) | ||||||
|  |  | ||||||
|  |     def not_refunded(self): | ||||||
|  |         return self.filter(date_refunded__isnull=True) | ||||||
|  |  | ||||||
|  |     def finished(self): | ||||||
|  |         return self.filter(date_finished__isnull=False) | ||||||
|  |  | ||||||
|  |     def games_only(self): | ||||||
|  |         return self.filter(type=Purchase.GAME) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Purchase(models.Model): | ||||||
|  |     PHYSICAL = "ph" | ||||||
|  |     DIGITAL = "di" | ||||||
|  |     DIGITALUPGRADE = "du" | ||||||
|  |     RENTED = "re" | ||||||
|  |     BORROWED = "bo" | ||||||
|  |     TRIAL = "tr" | ||||||
|  |     DEMO = "de" | ||||||
|  |     PIRATED = "pi" | ||||||
|  |     OWNERSHIP_TYPES = [ | ||||||
|  |         (PHYSICAL, "Physical"), | ||||||
|  |         (DIGITAL, "Digital"), | ||||||
|  |         (DIGITALUPGRADE, "Digital Upgrade"), | ||||||
|  |         (RENTED, "Rented"), | ||||||
|  |         (BORROWED, "Borrowed"), | ||||||
|  |         (TRIAL, "Trial"), | ||||||
|  |         (DEMO, "Demo"), | ||||||
|  |         (PIRATED, "Pirated"), | ||||||
|  |     ] | ||||||
|  |     GAME = "game" | ||||||
|  |     DLC = "dlc" | ||||||
|  |     SEASONPASS = "season_pass" | ||||||
|  |     BATTLEPASS = "battle_pass" | ||||||
|  |     TYPES = [ | ||||||
|  |         (GAME, "Game"), | ||||||
|  |         (DLC, "DLC"), | ||||||
|  |         (SEASONPASS, "Season Pass"), | ||||||
|  |         (BATTLEPASS, "Battle Pass"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     objects = PurchaseQueryset().as_manager() | ||||||
|  |  | ||||||
|  |     edition = models.ForeignKey(Edition, on_delete=models.CASCADE) | ||||||
|  |     platform = models.ForeignKey( | ||||||
|  |         Platform, on_delete=models.CASCADE, default=None, null=True, blank=True | ||||||
|  |     ) | ||||||
|  |     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.IntegerField(default=0) | ||||||
|  |     price_currency = models.CharField(max_length=3, default="USD") | ||||||
|  |     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, default="", null=True, blank=True) | ||||||
|  |     related_purchase = models.ForeignKey( | ||||||
|  |         "self", | ||||||
|  |         on_delete=models.SET_NULL, | ||||||
|  |         default=None, | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |         related_name="related_purchases", | ||||||
|  |     ) | ||||||
|  |     created_at = models.DateTimeField(auto_now_add=True) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         additional_info = [ | ||||||
|  |             self.get_type_display() if self.type != Purchase.GAME else "", | ||||||
|  |             ( | ||||||
|  |                 f"{self.edition.platform} version on {self.platform}" | ||||||
|  |                 if self.platform != self.edition.platform | ||||||
|  |                 else self.platform | ||||||
|  |             ), | ||||||
|  |             self.edition.year_released, | ||||||
|  |             self.get_ownership_type_display(), | ||||||
|  |         ] | ||||||
|  |         return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})" | ||||||
|  |  | ||||||
|  |     def is_game(self): | ||||||
|  |         return self.type == self.GAME | ||||||
|  |  | ||||||
|  |     def save(self, *args, **kwargs): | ||||||
|  |         if self.type == Purchase.GAME: | ||||||
|  |             self.name = "" | ||||||
|  |         elif self.type != Purchase.GAME and not self.related_purchase: | ||||||
|  |             raise ValidationError( | ||||||
|  |                 f"{self.get_type_display()} must have a related purchase." | ||||||
|  |             ) | ||||||
|  |         super().save(*args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SessionQuerySet(models.QuerySet): | ||||||
|  |     def total_duration_formatted(self): | ||||||
|  |         return format_duration(self.total_duration_unformatted()) | ||||||
|  |  | ||||||
|  |     def total_duration_unformatted(self): | ||||||
|  |         result = self.aggregate( | ||||||
|  |             duration=Sum(F("duration_calculated") + F("duration_manual")) | ||||||
|  |         ) | ||||||
|  |         return result["duration"] | ||||||
|  |  | ||||||
|  |     def calculated_duration_formatted(self): | ||||||
|  |         return format_duration(self.calculated_duration_unformatted()) | ||||||
|  |  | ||||||
|  |     def calculated_duration_unformatted(self): | ||||||
|  |         result = self.aggregate(duration=Sum(F("duration_calculated"))) | ||||||
|  |         return result["duration"] | ||||||
|  |  | ||||||
|  |     def without_manual(self): | ||||||
|  |         return self.exclude(duration_calculated__iexact=0) | ||||||
|  |  | ||||||
|  |     def only_manual(self): | ||||||
|  |         return self.filter(duration_calculated__iexact=0) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Session(models.Model): | ||||||
|  |     class Meta: | ||||||
|  |         get_latest_by = "timestamp_start" | ||||||
|  |  | ||||||
|  |     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, | ||||||
|  |         blank=True, | ||||||
|  |         default=None, | ||||||
|  |     ) | ||||||
|  |     note = models.TextField(blank=True, null=True) | ||||||
|  |     created_at = models.DateTimeField(auto_now_add=True) | ||||||
|  |     modified_at = models.DateTimeField(auto_now=True) | ||||||
|  |  | ||||||
|  |     objects = SessionQuerySet.as_manager() | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         mark = ", manual" if self.is_manual() else "" | ||||||
|  |         return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" | ||||||
|  |  | ||||||
|  |     def finish_now(self): | ||||||
|  |         self.timestamp_end = timezone.now() | ||||||
|  |  | ||||||
|  |     def start_now(): | ||||||
|  |         self.timestamp_start = timezone.now() | ||||||
|  |  | ||||||
|  |     def duration_seconds(self) -> timedelta: | ||||||
|  |         manual = timedelta(0) | ||||||
|  |         calculated = timedelta(0) | ||||||
|  |         if self.is_manual() and isinstance(self.duration_manual, timedelta): | ||||||
|  |             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()) | ||||||
|  |  | ||||||
|  |     def duration_formatted(self) -> str: | ||||||
|  |         result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") | ||||||
|  |         return result | ||||||
|  |  | ||||||
|  |     def is_manual(self) -> bool: | ||||||
|  |         return not self.duration_manual == timedelta(0) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def duration_sum(self) -> str: | ||||||
|  |         return Session.objects.all().total_duration_formatted() | ||||||
|  |  | ||||||
|  |     def save(self, *args, **kwargs) -> None: | ||||||
|  |         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) | ||||||
|  |  | ||||||
|  |         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" | ||||||
|  |     UNKNOWN = "un" | ||||||
|  |     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=UNKNOWN) | ||||||
|  |     created_at = models.DateTimeField(auto_now_add=True) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return f"{self.name} ({self.get_type_display()})" | ||||||
							
								
								
									
										100
									
								
								games/purchaseviews.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,100 @@ | |||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from django.contrib.auth.decorators import login_required | ||||||
|  | from django.core.paginator import Paginator | ||||||
|  | from django.db.models.manager import BaseManager | ||||||
|  | from django.http import HttpRequest, HttpResponse | ||||||
|  | from django.shortcuts import render | ||||||
|  | from django.template.loader import render_to_string | ||||||
|  | from django.urls import reverse | ||||||
|  |  | ||||||
|  | from games.models import Purchase | ||||||
|  | from games.views import dateformat | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @login_required | ||||||
|  | def list_purchases(request: HttpRequest) -> HttpResponse: | ||||||
|  |     context: dict[Any, Any] = {} | ||||||
|  |     page_number = request.GET.get("page", 1) | ||||||
|  |     limit = request.GET.get("limit", 10) | ||||||
|  |     purchases = Purchase.objects.order_by("-date_purchased") | ||||||
|  |     page_obj = None | ||||||
|  |     if int(limit) != 0: | ||||||
|  |         paginator = Paginator(purchases, limit) | ||||||
|  |         page_obj = paginator.get_page(page_number) | ||||||
|  |         purchases = page_obj.object_list | ||||||
|  |  | ||||||
|  |     context = { | ||||||
|  |         "title": "Manage purchases", | ||||||
|  |         "page_obj": page_obj or None, | ||||||
|  |         "elided_page_range": ( | ||||||
|  |             page_obj.paginator.get_elided_page_range( | ||||||
|  |                 page_number, on_each_side=1, on_ends=1 | ||||||
|  |             ) | ||||||
|  |             if page_obj | ||||||
|  |             else None | ||||||
|  |         ), | ||||||
|  |         "data": { | ||||||
|  |             "columns": [ | ||||||
|  |                 "Name", | ||||||
|  |                 "Platform", | ||||||
|  |                 "Price", | ||||||
|  |                 "Currency", | ||||||
|  |                 "Infinite", | ||||||
|  |                 "Purchased", | ||||||
|  |                 "Refunded", | ||||||
|  |                 "Finished", | ||||||
|  |                 "Dropped", | ||||||
|  |                 "Created", | ||||||
|  |                 "Actions", | ||||||
|  |             ], | ||||||
|  |             "rows": [ | ||||||
|  |                 [ | ||||||
|  |                     purchase.edition.name, | ||||||
|  |                     purchase.platform, | ||||||
|  |                     purchase.price, | ||||||
|  |                     purchase.price_currency, | ||||||
|  |                     purchase.infinite, | ||||||
|  |                     purchase.date_purchased.strftime(dateformat), | ||||||
|  |                     ( | ||||||
|  |                         purchase.date_refunded.strftime(dateformat) | ||||||
|  |                         if purchase.date_refunded | ||||||
|  |                         else "-" | ||||||
|  |                     ), | ||||||
|  |                     ( | ||||||
|  |                         purchase.date_finished.strftime(dateformat) | ||||||
|  |                         if purchase.date_finished | ||||||
|  |                         else "-" | ||||||
|  |                     ), | ||||||
|  |                     ( | ||||||
|  |                         purchase.date_dropped.strftime(dateformat) | ||||||
|  |                         if purchase.date_dropped | ||||||
|  |                         else "-" | ||||||
|  |                     ), | ||||||
|  |                     purchase.created_at.strftime(dateformat), | ||||||
|  |                     render_to_string( | ||||||
|  |                         "components/button_group_sm.html", | ||||||
|  |                         { | ||||||
|  |                             "buttons": [ | ||||||
|  |                                 { | ||||||
|  |                                     "href": reverse( | ||||||
|  |                                         "edit_purchase", args=[purchase.pk] | ||||||
|  |                                     ), | ||||||
|  |                                     "text": "Edit", | ||||||
|  |                                 }, | ||||||
|  |                                 { | ||||||
|  |                                     "href": reverse( | ||||||
|  |                                         "delete_purchase", args=[purchase.pk] | ||||||
|  |                                     ), | ||||||
|  |                                     "text": "Delete", | ||||||
|  |                                     "color": "red", | ||||||
|  |                                 }, | ||||||
|  |                             ] | ||||||
|  |                         }, | ||||||
|  |                     ), | ||||||
|  |                 ] | ||||||
|  |                 for purchase in purchases | ||||||
|  |             ], | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |     return render(request, "list_purchases.html", context) | ||||||
							
								
								
									
										30
									
								
								games/schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,30 @@ | |||||||
|  | import graphene | ||||||
|  |  | ||||||
|  | from games.graphql.mutations import GameMutation | ||||||
|  | from games.graphql.queries import ( | ||||||
|  |     DeviceQuery, | ||||||
|  |     EditionQuery, | ||||||
|  |     GameQuery, | ||||||
|  |     PlatformQuery, | ||||||
|  |     PurchaseQuery, | ||||||
|  |     SessionQuery, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Query( | ||||||
|  |     GameQuery, | ||||||
|  |     EditionQuery, | ||||||
|  |     DeviceQuery, | ||||||
|  |     PlatformQuery, | ||||||
|  |     PurchaseQuery, | ||||||
|  |     SessionQuery, | ||||||
|  |     graphene.ObjectType, | ||||||
|  | ): | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Mutation(GameMutation, graphene.ObjectType): | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | schema = graphene.Schema(query=Query, mutation=Mutation) | ||||||
							
								
								
									
										86
									
								
								games/sessionviews.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,86 @@ | |||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from django.contrib.auth.decorators import login_required | ||||||
|  | from django.core.paginator import Paginator | ||||||
|  | from django.http import HttpRequest, HttpResponse | ||||||
|  | from django.shortcuts import render | ||||||
|  | from django.template.loader import render_to_string | ||||||
|  | from django.urls import reverse | ||||||
|  |  | ||||||
|  | from common.time import format_duration | ||||||
|  | from games.models import Session | ||||||
|  | from games.views import dateformat, datetimeformat, timeformat | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @login_required | ||||||
|  | def list_sessions(request: HttpRequest) -> HttpResponse: | ||||||
|  |     context: dict[Any, Any] = {} | ||||||
|  |     page_number = request.GET.get("page", 1) | ||||||
|  |     limit = request.GET.get("limit", 10) | ||||||
|  |     sessions = Session.objects.order_by("-created_at") | ||||||
|  |     page_obj = None | ||||||
|  |     if int(limit) != 0: | ||||||
|  |         paginator = Paginator(sessions, limit) | ||||||
|  |         page_obj = paginator.get_page(page_number) | ||||||
|  |         sessions = page_obj.object_list | ||||||
|  |  | ||||||
|  |     context = { | ||||||
|  |         "title": "Manage sessions", | ||||||
|  |         "page_obj": page_obj or None, | ||||||
|  |         "elided_page_range": ( | ||||||
|  |             page_obj.paginator.get_elided_page_range( | ||||||
|  |                 page_number, on_each_side=1, on_ends=1 | ||||||
|  |             ) | ||||||
|  |             if page_obj | ||||||
|  |             else None | ||||||
|  |         ), | ||||||
|  |         "data": { | ||||||
|  |             "columns": [ | ||||||
|  |                 "Name", | ||||||
|  |                 "Date", | ||||||
|  |                 "Duration", | ||||||
|  |                 "Duration (manual)", | ||||||
|  |                 "Device", | ||||||
|  |                 "Created", | ||||||
|  |                 "Actions", | ||||||
|  |             ], | ||||||
|  |             "rows": [ | ||||||
|  |                 [ | ||||||
|  |                     session.purchase.edition.name, | ||||||
|  |                     f"{session.timestamp_start.strftime(datetimeformat)}{f" — {session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}", | ||||||
|  |                     ( | ||||||
|  |                         format_duration(session.duration_calculated, "%2.1H hours") | ||||||
|  |                         if session.duration_calculated | ||||||
|  |                         else "-" | ||||||
|  |                     ), | ||||||
|  |                     ( | ||||||
|  |                         format_duration(session.duration_manual) | ||||||
|  |                         if session.duration_manual | ||||||
|  |                         else "-" | ||||||
|  |                     ), | ||||||
|  |                     session.device, | ||||||
|  |                     session.created_at.strftime(dateformat), | ||||||
|  |                     render_to_string( | ||||||
|  |                         "components/button_group_sm.html", | ||||||
|  |                         { | ||||||
|  |                             "buttons": [ | ||||||
|  |                                 { | ||||||
|  |                                     "href": reverse("edit_session", args=[session.pk]), | ||||||
|  |                                     "text": "Edit", | ||||||
|  |                                 }, | ||||||
|  |                                 { | ||||||
|  |                                     "href": reverse( | ||||||
|  |                                         "delete_session", args=[session.pk] | ||||||
|  |                                     ), | ||||||
|  |                                     "text": "Delete", | ||||||
|  |                                     "color": "red", | ||||||
|  |                                 }, | ||||||
|  |                             ] | ||||||
|  |                         }, | ||||||
|  |                     ), | ||||||
|  |                 ] | ||||||
|  |                 for session in sessions | ||||||
|  |             ], | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |     return render(request, "list_purchases.html", context) | ||||||
							
								
								
									
										3097
									
								
								games/static/base.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexMono-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSans-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSansCondensed-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSerif-Bold.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSerif-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/edition_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 292 KiB | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/edition_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 321 KiB | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/game_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 22 KiB | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/game_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 306 KiB | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/loading.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 787 B | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/purchase_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 312 KiB | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/purchase_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 364 KiB | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/schedule.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 42 KiB | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/wikidata.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 504 B | 
							
								
								
									
										24
									
								
								games/static/js/add_edition.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,24 @@ | |||||||
|  | import { syncSelectInputUntilChanged } from "./utils.js"; | ||||||
|  |  | ||||||
|  | let syncData = [ | ||||||
|  |   { | ||||||
|  |     source: "#id_game", | ||||||
|  |     source_value: "dataset.name", | ||||||
|  |     target: "#id_name", | ||||||
|  |     target_value: "value", | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     source: "#id_game", | ||||||
|  |     source_value: "textContent", | ||||||
|  |     target: "#id_sort_name", | ||||||
|  |     target_value: "value", | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     source: "#id_game", | ||||||
|  |     source_value: "dataset.year", | ||||||
|  |     target: "#id_year_released", | ||||||
|  |     target_value: "value", | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | syncSelectInputUntilChanged(syncData, "form"); | ||||||
							
								
								
									
										12
									
								
								games/static/js/add_game.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -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"); | ||||||
							
								
								
									
										47
									
								
								games/static/js/add_purchase.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,47 @@ | |||||||
|  | import { | ||||||
|  |   syncSelectInputUntilChanged, | ||||||
|  |   getEl, | ||||||
|  |   disableElementsWhenTrue, | ||||||
|  |   disableElementsWhenValueNotEqual, | ||||||
|  | } from "./utils.js"; | ||||||
|  |  | ||||||
|  | let syncData = [ | ||||||
|  |   { | ||||||
|  |     source: "#id_edition", | ||||||
|  |     source_value: "dataset.platform", | ||||||
|  |     target: "#id_platform", | ||||||
|  |     target_value: "value", | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | syncSelectInputUntilChanged(syncData, "form"); | ||||||
|  |  | ||||||
|  | function setupElementHandlers() { | ||||||
|  |   disableElementsWhenTrue("#id_type", "game", [ | ||||||
|  |     "#id_name", | ||||||
|  |     "#id_related_purchase", | ||||||
|  |   ]); | ||||||
|  |   disableElementsWhenValueNotEqual( | ||||||
|  |     "#id_type", | ||||||
|  |     ["game", "dlc"], | ||||||
|  |     ["#id_date_finished"] | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | document.addEventListener("DOMContentLoaded", setupElementHandlers); | ||||||
|  | document.addEventListener("htmx:afterSwap", setupElementHandlers); | ||||||
|  | getEl("#id_type").onchange = () => { | ||||||
|  |   setupElementHandlers(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | document.body.addEventListener("htmx:beforeRequest", function (event) { | ||||||
|  |   // Assuming 'Purchase1' is the element that triggers the HTMX request | ||||||
|  |   if (event.target.id === "id_edition") { | ||||||
|  |     var idEditionValue = document.getElementById("id_edition").value; | ||||||
|  |  | ||||||
|  |     // Condition to check - replace this with your actual logic | ||||||
|  |     if (idEditionValue != "") { | ||||||
|  |       event.preventDefault(); // This cancels the HTMX request | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }); | ||||||
							
								
								
									
										23
									
								
								games/static/js/add_session.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,23 @@ | |||||||
|  | import { toISOUTCString } from "./utils.js"; | ||||||
|  |  | ||||||
|  | for (let button of document.querySelectorAll("[data-target]")) { | ||||||
|  |   let target = button.getAttribute("data-target"); | ||||||
|  |   let type = button.getAttribute("data-type"); | ||||||
|  |   let targetElement = document.querySelector(`#id_${target}`); | ||||||
|  |   button.addEventListener("click", (event) => { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     if (type == "now") { | ||||||
|  |       targetElement.value = toISOUTCString(new Date()); | ||||||
|  |     } else if (type == "copy") { | ||||||
|  |       const oppositeName = | ||||||
|  |         targetElement.name == "timestamp_start" | ||||||
|  |           ? "timestamp_end" | ||||||
|  |           : "timestamp_start"; | ||||||
|  |       document.querySelector(`[name='${oppositeName}']`).value = | ||||||
|  |         targetElement.value; | ||||||
|  |     } else if (type == "toggle") { | ||||||
|  |       if (targetElement.type == "datetime-local") targetElement.type = "text"; | ||||||
|  |       else targetElement.type = "datetime-local"; | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | } | ||||||