155 Commits

Author SHA1 Message Date
51c25659a9 djhtml formatting
All checks were successful
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-04-30 12:04:16 +02:00
973dda59d2 Improve game overview header 2024-04-30 12:03:52 +02:00
64edca9ffa Use display name in session list
All checks were successful
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-04-29 19:21:05 +02:00
86e25b84ab Allow deleting purchases
All checks were successful
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m47s
2024-04-29 16:35:54 +02:00
edc1d062bc Update gunicorn to version 22.0.0
All checks were successful
Django CI/CD / test (push) Successful in 1m46s
Django CI/CD / build-and-push (push) Successful in 2m28s
2024-04-17 12:28:10 +02:00
12a517c9fa Update sqlparse to version 0.5
All checks were successful
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m58s
2024-04-16 11:44:24 +02:00
c1882f66e3 Improve purchase name consistency on stats page
All checks were successful
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m0s
2024-04-15 13:55:17 +02:00
1e87e67eb1 Reformat HTML with djhtml
All checks were successful
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-04-04 11:27:33 +02:00
84552e088b Update more dependencies 2024-04-04 11:27:14 +02:00
79dc8ae25c Update black
Some checks failed
Django CI/CD / test (push) Failing after 44s
Django CI/CD / build-and-push (push) Has been skipped
2024-04-04 11:09:09 +02:00
cee06e4f64 Update dependencies
All checks were successful
Django CI/CD / test (push) Successful in 47s
Django CI/CD / build-and-push (push) Successful in 1m47s
2024-04-04 10:46:59 +02:00
d9b5f0eab2 stats: add monthly playtimes
All checks were successful
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 1m54s
2024-04-02 08:18:58 +02:00
ff28600710 Fix timestamp minutes on game page
All checks were successful
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m2s
Fixed #72
2024-03-27 14:38:00 +01:00
7517bf5f37 Add stats for dropped purchases
All checks were successful
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-03-10 22:48:46 +01:00
780a04d13f Do not edit sort_name invisibly
All checks were successful
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m0s
Fixes #64
2024-03-04 16:50:37 +01:00
fd04e9fa77 Sort prefetch instead of the result
All checks were successful
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Successful in 1m52s
order_by on the final queryset results in duplicating editions, 1 for each purchase
to fix it we sort the thing we actually want to sort - non-game purchases - in a prefetch earlier in the code
2024-02-18 12:31:03 +01:00
18902aedac Reformat
All checks were successful
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 1m53s
2024-02-18 09:03:35 +01:00
f9e37e9b1e Sort purchases also by date purchased
Some checks failed
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Has been cancelled
2024-02-18 09:02:08 +01:00
c747cd1fd8 Reformat
All checks were successful
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-02-10 09:50:53 +01:00
6a5457191a Add logout button 2024-02-10 09:48:09 +01:00
76f6d0c377 Fix CSS bug 2024-02-10 09:03:16 +01:00
ae93703c08 Remove login_required from clone_session_by_id
All checks were successful
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m35s
2024-02-09 22:27:28 +01:00
c55176090c Temporarily disable tests
All checks were successful
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Successful in 1m41s
2024-02-09 22:08:49 +01:00
081b8a92de Require login by default
Some checks failed
Django CI/CD / test (push) Failing after 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-02-09 22:03:24 +01:00
d02a60675f Render notes as Markdown
Some checks failed
Django CI/CD / test (push) Failing after 1m5s
Django CI/CD / build-and-push (push) Has been skipped
2024-02-09 21:37:39 +01:00
4670568acb Add .DS_Store to .gitignore
All checks were successful
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 1m34s
2024-01-15 22:09:29 +01:00
4b75a1dea9 Increase session count on game overview when starting a new session
All checks were successful
Django CI/CD / test (push) Successful in 50s
Django CI/CD / build-and-push (push) Successful in 1m32s
2024-01-15 21:41:25 +01:00
e2b7ff2e15 Remove cruft
All checks were successful
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m30s
2024-01-15 19:17:27 +01:00
b94aa49fc3 Fix title not being displayed on the Recent sessions page 2024-01-15 19:17:24 +01:00
73a92e5636 Mark refunded purchases red
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-01-15 11:19:18 +01:00
42b28665e1 Version 1.5.2
All checks were successful
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2024-01-14 21:28:38 +01:00
6ba187f8e4 Make it possible to end session from game overview 2024-01-14 21:27:18 +01:00
a765fd8d00 More game overview optimizations
All checks were successful
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 1m37s
2024-01-14 17:04:06 +01:00
854e3cc54a Do not copy notes when cloning session
All checks were successful
Django CI/CD / test (push) Successful in 1m13s
Django CI/CD / build-and-push (push) Successful in 1m45s
2024-01-14 13:05:45 +01:00
2d8eb32e90 Remove cruft
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m29s
2024-01-10 17:13:59 +01:00
1f1ed79ee5 Optimize session listing 2024-01-10 16:57:01 +01:00
01fd7bad69 Remove cruft 2024-01-10 15:55:08 +01:00
44f49e5974 Session list: speed up starting new sessions
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-01-10 15:54:09 +01:00
0cf3411f63 Make ending session from session list faster
All checks were successful
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 1m40s
2024-01-10 15:12:45 +01:00
aa669710e1 Change update_session to template partial
Some checks failed
Django CI/CD / test (push) Failing after 1m3s
Django CI/CD / build-and-push (push) Has been skipped
2024-01-10 14:10:13 +01:00
242833f886 Make it possible to drop purchases, or consider them infinite
Some checks failed
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2024-01-03 22:35:39 +01:00
0cdfd3c298 Stats: optimize
All checks were successful
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-01-03 21:35:47 +01:00
a98b4839dd Fix wrong unfinished purchases calculation
All checks were successful
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 1m42s
2024-01-02 20:03:59 +01:00
1999f13cf2 stats: add first and last play
All checks were successful
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-01-01 18:42:14 +01:00
8466f67c86 Fix errors caused by empty values
All checks were successful
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 1m41s
2024-01-01 18:21:50 +01:00
d9fbb4b896 Add title to stats page
Some checks failed
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2023-12-15 10:58:15 +01:00
4ff3692606 Remove duplicate block
All checks were successful
Django CI/CD / test (push) Successful in 1m34s
Django CI/CD / build-and-push (push) Successful in 1m29s
2023-11-30 18:05:52 +01:00
8289c48896 Fix CI error
All checks were successful
Django CI/CD / test (push) Successful in 1m14s
Django CI/CD / build-and-push (push) Successful in 1m24s
2023-11-30 17:44:31 +01:00
d1b9202337 Update poetry.lock
Some checks failed
Django CI/CD / test (push) Failing after 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-30 17:35:44 +01:00
fde93cb875 Organize better 2023-11-30 17:35:44 +01:00
d1c3ac6079 Revert "Move GraphQL to separata app"
This reverts commit 6ac4209492.
2023-11-30 17:35:44 +01:00
d921c2d8a6 Revert "Add UpdateGameMutation"
This reverts commit e9e61403a9.
2023-11-30 17:35:44 +01:00
52513e1ed8 Add UpdateGameMutation 2023-11-30 17:35:44 +01:00
cb380814a7 Move GraphQL to separata app 2023-11-30 17:35:44 +01:00
5ef8c07f30 Initial working API 2023-11-30 17:35:44 +01:00
9573c3b8ff Better formatting
All checks were successful
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 1m22s
2023-11-29 22:26:43 +01:00
c4354a1380 Update poetry.lock
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m14s
2023-11-29 22:22:23 +01:00
a245b6ff0f Fix longest session formatting
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m24s
Put space between hours and minutes
2023-11-29 09:08:10 +01:00
6329d380b7 Editions are unique if name, platform OR year is different
All checks were successful
Django CI/CD / test (push) Successful in 1m25s
Django CI/CD / build-and-push (push) Successful in 1m20s
2023-11-28 14:44:11 +01:00
76fbc39fed Disable hx-boost
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m17s
2023-11-28 14:29:56 +01:00
4b6734c173 Add width, height, alt to images 2023-11-28 14:29:11 +01:00
b505b5b430 Stats: add highest session average
All checks were successful
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m23s
2023-11-21 21:57:17 +01:00
87553ebdc5 Add djlint pre-commit hook
All checks were successful
Django CI/CD / test (push) Successful in 1m13s
Django CI/CD / build-and-push (push) Successful in 1m15s
2023-11-21 18:19:25 +01:00
ba4fc0cac5 Do not trigger hx-boost for non-submit buttons
All checks were successful
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 1m20s
2023-11-21 18:12:58 +01:00
8cb0276215 Use better way to find out if model record exists 2023-11-21 18:03:01 +01:00
f9a51ee83d Remove experimental layout 2023-11-21 18:03:01 +01:00
c9deba7d65 Add stats for most sessions, longest session 2023-11-21 17:02:44 +01:00
c55fbe86b5 Support HTMX with Django Debug Toolbar 2023-11-21 16:59:21 +01:00
0e93993498 Add django-debug-toolbar 2023-11-21 14:42:37 +01:00
9fccdfbff0 Make links colorful 2023-11-20 23:07:11 +01:00
d78139a5b3 Display finished DLCs in stats better
All checks were successful
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m26s
2023-11-20 21:56:16 +01:00
7dc43fbf77 Fix wrong export name 2023-11-20 21:54:51 +01:00
5442926457 Allow DLC to have date_finished set
All checks were successful
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m35s
2023-11-20 21:42:23 +01:00
db4c635260 Remote JavaScript files 2023-11-20 21:25:21 +01:00
4a1d08d4df Fix CI, re-add test step
All checks were successful
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 1m45s
2023-11-18 11:10:33 +01:00
c35b539c42 Merge sessions and notes
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m9s
2023-11-17 21:20:33 +01:00
bbe5e072b2 Don't display prices if zero 2023-11-17 21:10:56 +01:00
6fc2f623dc Apply djlint 2023-11-17 21:06:57 +01:00
9481bd5fef Add pre-commit
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m33s
2023-11-17 09:34:51 +01:00
4083165123 Use the black profile for isort 2023-11-17 09:15:18 +01:00
45bb2681c7 Use isort on migrations 2023-11-17 09:15:06 +01:00
dbb8ec3f9a Handle empty edition_id 2023-11-17 09:14:25 +01:00
206b5f6d46 Prevent HTMX from messing up the initial state
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m15s
2023-11-16 20:33:56 +01:00
b7e14ecc83 Account for no sessions
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m21s
2023-11-16 20:29:08 +01:00
912e010729 Enable hx-boost everywhere
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m18s
2023-11-16 19:56:08 +01:00
a485237456 Fix form not syncing due to HTMX
All checks were successful
Django CI/CD / build-and-push (push) Successful in 2m38s
2023-11-16 19:03:16 +01:00
f5faf92ee0 Fix error
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m57s
2023-11-16 16:53:59 +01:00
07452d8c43 Re-instance gitea actions
Some checks failed
Django CI/CD / test (push) Failing after 34s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-16 16:51:52 +01:00
229a79d266 Update .drone.yml testing
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-16 16:30:17 +01:00
c6ed577fe3 Formatting
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-16 16:27:41 +01:00
171e4779a3 Move static files in prod 2023-11-16 16:27:41 +01:00
79f94e5984 Fix docker-compose.yml 2023-11-16 16:27:41 +01:00
ccebcb89c6 Improve Dockerfile
Major inspiration (aka direct theft) from https://github.com/wemake-services/wemake-django-template
2023-11-16 16:27:41 +01:00
fe0a6b39e3 Fix .dockerignore 2023-11-16 16:27:41 +01:00
6a495f951f Remove Django admin 2023-11-16 16:27:41 +01:00
c8646d0a0c Update dependencies 2023-11-16 16:27:41 +01:00
f2bb15e669 Fix naive date 2023-11-16 16:27:41 +01:00
c49177d63c isort 2023-11-16 16:27:41 +01:00
bd8d30eac1 Improve time-related stuff
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-16 16:27:41 +01:00
c44d8bf427 Improve time-related stuff
All checks were successful
continuous-integration/drone/push Build is passing
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-15 19:14:09 +01:00
3f037b4c7c Only allow choosing purchases of selected edition
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-15 14:25:42 +01:00
8783d1fc8e Name and related_purchase validation for non-games 2023-11-15 13:04:47 +01:00
9a1d24dbfd Sort imports, remove cruft 2023-11-15 12:19:31 +01:00
4720660cff Fix wrong playrange ordering 2023-11-15 10:40:52 +01:00
e158bc0623 Improve how editions and purchases are displayed
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-15 10:37:24 +01:00
8982fc5086 Game View: order editions by year 2023-11-14 21:19:36 +01:00
729e1d939b Version 1.5.1
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2023-11-14 21:10:42 +01:00
2b4683e489 Improve and cleanup ConditionalElementHandler 2023-11-14 21:09:43 +01:00
cce810e8cf Improve purchase __str__ 2023-11-14 19:55:56 +01:00
62cd17f702 Disallow choosing non-game purchase as related purchase 2023-11-14 19:55:19 +01:00
f31280c682 Version 1.5.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-14 19:31:17 +01:00
a745d16ec3 Order purchases by date on game view 2023-11-14 19:30:19 +01:00
ae079e36ec Add purchase types 2023-11-14 19:27:00 +01:00
c8a3212b77 CI: run migrations before tests
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-11-12 08:11:22 +01:00
d211326c3f Make sure empty stats are 0
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-12 08:01:12 +01:00
270a291f05 Change stats years to 2000 up to current year 2023-11-12 07:50:12 +01:00
13b750ca92 Add stat for finished this year's games 2023-11-12 07:40:29 +01:00
015b6db2f7 Fix detecting manual durations
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-11 15:02:28 +01:00
667b161fff Remove deprecated USE_L10N
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-10 21:37:13 +01:00
5958cbf4a6 Add more tests 2023-11-10 21:34:36 +01:00
3b37f2c3f0 Fix edge case in format_duration
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #65

```python
def test_specific_precise_if_unncessary(self):
        delta = timedelta(hours=2, minutes=40)
        result = format_duration(delta, "%02.0H:%02.0m")
        self.assertEqual(result, "02:40")
```
This test fails by returning "03:40" instead. The problem is in the way `format_duration` handles fractional hours.
To fix it, we need to switch between using hours and fractional hours
depending on if minutes are present in the formatted string.
2023-11-10 20:07:41 +01:00
4517ff2b5a Fix ordering
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:43:17 +01:00
884ce13e26 Also prefill year between game and edition
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:20:12 +01:00
dd219bae9d Version 1.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:11:43 +01:00
60d29090a1 Adding new games is easier 2023-11-09 21:11:28 +01:00
1bc3ca057b Refactor, remove cruft 2023-11-09 19:35:57 +01:00
c2c0886451 Add backlog decrease count 2023-11-09 19:15:49 +01:00
b0be7b5887 Fix sort names getting mangled
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 15:41:46 +01:00
099d989f16 Pre-fill year when adding edition 2023-11-09 15:20:30 +01:00
a879360ebd UX improvements
All checks were successful
continuous-integration/drone/push Build is passing
* 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 and sort name when selecting game
  * new purchase: platform when selecting edition
2023-11-09 14:49:00 +01:00
866f2526e6 Fix hardcoded year 2023-11-09 10:10:44 +01:00
ce3c4b55f0 Order devices alphabetically on new session form
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 10:09:32 +01:00
c52cd822ae Use safe_division in more places 2023-11-09 10:06:14 +01:00
cdc6ca1324 Fix potential division by zero
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 09:18:49 +01:00
e7ed349356 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-08 18:13:48 +01:00
5052ca7dbf Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-08 16:24:22 +01:00
f408bfd927 stats: change overall stats table layout 2023-11-08 15:48:06 +01:00
666dee33ba Model changes
All checks were successful
continuous-integration/drone/push Build is passing
* 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
2023-11-06 19:48:12 +01:00
e0b09e051a simplify playtime range display 2023-11-06 12:05:39 +01:00
4552cf7616 Version 1.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 15:10:56 +01:00
a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
e67aa3fda1 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 20:12:32 +01:00
8423fd02b4 Extend stats range to 2018
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:32:57 +01:00
2bd07e5f2d Remove cruft
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:14:57 +01:00
058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
f13ed8a078 Reorder imports in views.py
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 09:53:28 +01:00
02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
391fcc79a8 Version 1.2.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-01 20:35:58 +01:00
57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
a5b2854bf6 Add a button to start session from game overview
All checks were successful
continuous-integration/drone/push Build is passing
Fix #62
2023-10-13 19:22:43 +02:00
518c0ecd56 Add more time tests for fractional numbers
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 17:01:33 +02:00
a6cd7a3430 Do not format as float if no precision specified
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 16:58:12 +02:00
81 changed files with 4070 additions and 1010 deletions

View File

@ -5,4 +5,13 @@
.venv .venv
.vscode .vscode
node_modules node_modules
src/timetracker/static/* static
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile

View File

@ -5,11 +5,12 @@ 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-prod - name: build-prod

36
.github/workflows/build-docker.yml vendored Normal file
View 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

5
.gitignore vendored
View File

@ -1,9 +1,10 @@
__pycache__ __pycache__
.mypy_cache .mypy_cache
.pytest_cache .pytest_cache
.venv .venv/
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
/static/ /static/
dist/ dist/
.DS_Store

15
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,15 @@
repos:
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
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

View File

@ -1,3 +1,114 @@
## Unreleased
## New
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting 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
* stats: improve purchase name consistency
* session list: use display name instead of sort name
## Fixed
* Fix title not being displayed on the Recent sessions page
## 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 ## 1.1.2 / 2023-10-13 16:30+02:00
### Enhancements ### Enhancements

View File

@ -1,27 +1,45 @@
FROM node as css FROM python:3.12.0-slim-bullseye
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
FROM python:3.10.9-slim-bullseye 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 1.1.2 RUN apt-get update && apt-get upgrade -y \
ENV PROD 1 && apt-get install --no-install-recommends -y \
ENV PYTHONUNBUFFERED=1 bash \
curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \
&& 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 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/static/base.css /home/timetracker/app/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
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]

View File

@ -23,6 +23,12 @@
font-style: normal; font-style: normal;
} }
a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
}
form label { form label {
@apply dark:text-slate-400; @apply dark:text-slate-400;
} }
@ -66,6 +72,16 @@ textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; @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) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -101,3 +117,31 @@ th label {
.basic-button { .basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out; @apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded 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;
}

View File

@ -1,12 +1,5 @@
import re import re
from datetime import datetime, timedelta from datetime import timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):
@ -44,7 +37,7 @@ def format_duration(
# timestamps where end is before start # timestamps where end is before start
if seconds_total < 0: if seconds_total < 0:
seconds_total = 0 seconds_total = 0
days = hours = minutes = seconds = 0 days = hours = hours_float = minutes = seconds = 0
remainder = seconds = seconds_total remainder = seconds = seconds_total
if "%d" in format_string: if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds) days, remainder = divmod(seconds_total, day_seconds)
@ -55,7 +48,7 @@ def format_duration(
minutes, seconds = divmod(remainder, minute_seconds) minutes, seconds = divmod(remainder, minute_seconds)
literals = { literals = {
"d": str(days), "d": str(days),
"H": str(hours), "H": str(hours) if "m" not in format_string else str(hours_float),
"m": str(minutes), "m": str(minutes),
"s": str(seconds), "s": str(seconds),
"r": str(seconds_total), "r": str(seconds_total),
@ -66,9 +59,12 @@ def format_duration(
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string) match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
if match: if match:
format_spec = match.group(1) format_spec = match.group(1)
if format_spec: if "." in format_spec:
# Format the number according to the specifier # Format the number as float if precision is specified
replacement = f"{float(replacement):{format_spec}f}" 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 # Replace the format specifier with the formatted number
formatted_string = re.sub( formatted_string = re.sub(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string rf"%\d*\.?\d*{pattern}", replacement, formatted_string

9
common/utils.py Normal file
View File

@ -0,0 +1,9 @@
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

View File

@ -10,13 +10,14 @@ services:
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000" user: "1000"
volumes: volumes:
- "static-files:/home/timetracker/app/static" - "static-files:/var/www/django/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: caddy image: caddy
volumes: volumes:
- "static-files:/usr/share/caddy" - "static-files:/usr/share/caddy:ro"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile" - "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports: ports:
- "8000:8000" - "8000:8000"
@ -26,3 +27,4 @@ services:
volumes: volumes:
static-files: static-files:

View File

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from games.models import Game, Platform, Purchase, Session, Edition, Device 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)

View File

@ -1,12 +1,12 @@
from django import forms from django import forms
from django.urls import reverse
from games.models import Game, Platform, Purchase, Session, Edition, Device from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M" attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
) )
autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
@ -15,10 +15,12 @@ class SessionForm(forms.ModelForm):
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name") # queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# ) # )
purchase = forms.ModelChoiceField( purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name"), queryset=Purchase.objects.order_by("edition__sort_name"),
widget=autofocus_select_widget, widget=forms.Select(attrs={"autofocus": "autofocus"}),
) )
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta: class Meta:
widgets = { widgets = {
"timestamp_start": custom_datetime_widget, "timestamp_start": custom_datetime_widget,
@ -37,19 +39,51 @@ class SessionForm(forms.ModelForm):
class EditionChoiceField(forms.ModelChoiceField): class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str: def label_from_instance(self, obj) -> str:
return f"{obj.name} ({obj.platform}, {obj.year_released})" 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 value:
option["attrs"]["data-platform"] = value.instance.platform.id
return option
class PurchaseForm(forms.ModelForm): 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( edition = EditionChoiceField(
queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) 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: class Meta:
widgets = { widgets = {
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
@ -57,27 +91,72 @@ class PurchaseForm(forms.ModelForm):
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished",
"date_dropped",
"infinite",
"price", "price",
"price_currency", "price_currency",
"ownership_type", "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): class EditionForm(forms.ModelForm):
game = forms.ModelChoiceField( game = GameModelChoiceField(
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta: class Meta:
model = Edition model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"] fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
class Meta: class Meta:
model = Game model = Game
fields = ["name", "wikidata"] fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}

View File

@ -0,0 +1 @@
from .game import Mutation as GameMutation

View 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()

View 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

View 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()

View 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()

View 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

View 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()

View 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()

View 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
View 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__"

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30 # Generated by Django 4.1.5 on 2023-01-19 18:30
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):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29 # Generated by Django 4.1.5 on 2023-02-18 16:29
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):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06 # Generated by Django 4.1.5 on 2023-02-18 19:06
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):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59 # Generated by Django 4.1.5 on 2023-02-18 19:59
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):

View File

@ -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",
),
),
]

View File

@ -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",
),
]

View File

@ -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",
),
),
]

View 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),
]

View 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")},
),
]

View 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),
),
]

View 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),
]

View 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",
),
]

View 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),
),
]

View 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),
]

View 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),
]

View 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,
),
),
]

View 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",
),
),
]

View 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),
]

View 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",
),
),
]

View 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),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View 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")},
),
]

View File

@ -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),
),
]

View File

@ -1,30 +1,54 @@
from datetime import datetime, timedelta from datetime import timedelta
from typing import Any
from zoneinfo import ZoneInfo
from common.time import format_duration from django.core.exceptions import ValidationError
from django.conf import settings
from django.db import models from django.db import models
from django.db.models import F, Manager, Sum from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.time import format_duration
class Game(models.Model): class Game(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
wikidata = models.CharField(max_length=50) 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)
def __str__(self): def __str__(self):
return self.name return self.name
class Edition(models.Model): class Edition(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE) sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(default=datetime.today().year) platform = models.ForeignKey(
"Platform", on_delete=models.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) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name 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): class Purchase(models.Model):
@ -46,27 +70,73 @@ class Purchase(models.Model):
(DEMO, "Demo"), (DEMO, "Demo"),
(PIRATED, "Pirated"), (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) edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE) platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False)
price = models.IntegerField(default=0) price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField( ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL 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(
"Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
platform_info = self.platform additional_info = [
if self.platform != self.edition.platform: self.get_type_display() if self.type != Purchase.GAME else "",
platform_info = f"{self.edition.platform} version on {self.platform}" f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})" 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 Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = 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): def __str__(self):
return self.name return self.name
@ -84,30 +154,41 @@ class SessionQuerySet(models.QuerySet):
class Session(models.Model): class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True) device = models.ForeignKey(
"Device",
on_delete=models.CASCADE,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True) 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() objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.duration_manual != None else "" mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) self.timestamp_end = timezone.now()
def start_now(): def start_now():
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE)) self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
calculated = timedelta(0) calculated = timedelta(0)
if not self.duration_manual in (None, 0, timedelta(0)): if self.is_manual():
manual = self.duration_manual manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None: if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start calculated = self.timestamp_end - self.timestamp_start
@ -117,6 +198,9 @@ class Session(models.Model):
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
return result return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property @property
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted() return Session.objects.all().total_duration_formatted()
@ -126,6 +210,12 @@ class Session(models.Model):
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
if not 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) super(Session, self).save(*args, **kwargs)
@ -135,15 +225,18 @@ class Device(models.Model):
HANDHELD = "ha" HANDHELD = "ha"
MOBILE = "mo" MOBILE = "mo"
SBC = "sbc" SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [ DEVICE_TYPES = [
(PC, "PC"), (PC, "PC"),
(CONSOLE, "Console"), (CONSOLE, "Console"),
(HANDHELD, "Handheld"), (HANDHELD, "Handheld"),
(MOBILE, "Mobile"), (MOBILE, "Mobile"),
(SBC, "Single-board computer"), (SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC) type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_type_display()})" return f"{self.name} ({self.get_type_display()})"

30
games/schema.py Normal file
View 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)

View File

@ -1,5 +1,5 @@
/* /*
! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com ! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com
*/ */
/* /*
@ -32,9 +32,11 @@
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default. 5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default. 6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/ */
html { html,
:host {
line-height: 1.5; line-height: 1.5;
/* 1 */ /* 1 */
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@ -44,12 +46,14 @@ html {
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal; font-variation-settings: normal;
/* 6 */ /* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
} }
/* /*
@ -121,8 +125,10 @@ strong {
} }
/* /*
1. Use the user's configured `mono` font family by default. 1. Use the user's configured `mono` font-family by default.
2. Correct the odd `em` font sizing in all browsers. 2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/ */
code, code,
@ -131,8 +137,12 @@ samp,
pre { pre {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-size: 1em; font-feature-settings: normal;
/* 2 */ /* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em;
/* 4 */
} }
/* /*
@ -201,6 +211,8 @@ textarea {
/* 1 */ /* 1 */
line-height: inherit; line-height: inherit;
/* 1 */ /* 1 */
letter-spacing: inherit;
/* 1 */
color: inherit; color: inherit;
/* 1 */ /* 1 */
margin: 0; margin: 0;
@ -224,9 +236,9 @@ select {
*/ */
button, button,
[type='button'], input:where([type='button']),
[type='reset'], input:where([type='reset']),
[type='submit'] { input:where([type='submit']) {
-webkit-appearance: button; -webkit-appearance: button;
/* 1 */ /* 1 */
background-color: transparent; background-color: transparent;
@ -567,10 +579,26 @@ select {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
} }
@media (forced-colors: active) {
[type='checkbox']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='radio']:checked { [type='radio']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
} }
@media (forced-colors: active) {
[type='radio']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
border-color: transparent; border-color: transparent;
background-color: currentColor; background-color: currentColor;
@ -585,6 +613,14 @@ select {
background-repeat: no-repeat; background-repeat: no-repeat;
} }
@media (forced-colors: active) {
[type='checkbox']:indeterminate {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
border-color: transparent; border-color: transparent;
background-color: currentColor; background-color: currentColor;
@ -653,6 +689,10 @@ select {
--tw-backdrop-opacity: ; --tw-backdrop-opacity: ;
--tw-backdrop-saturate: ; --tw-backdrop-saturate: ;
--tw-backdrop-sepia: ; --tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
} }
::backdrop { ::backdrop {
@ -703,6 +743,10 @@ select {
--tw-backdrop-opacity: ; --tw-backdrop-opacity: ;
--tw-backdrop-saturate: ; --tw-backdrop-saturate: ;
--tw-backdrop-sepia: ; --tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
} }
.container { .container {
@ -755,6 +799,10 @@ select {
position: absolute; position: absolute;
} }
.relative {
position: relative;
}
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
@ -791,12 +839,29 @@ select {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.my-6 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.mb-1 { .mb-1 {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.mb-4 { .mb-10 {
margin-bottom: 1rem; margin-bottom: 2.5rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.ml-1 {
margin-left: 0.25rem;
} }
.ml-2 { .ml-2 {
@ -811,18 +876,14 @@ select {
margin-top: 1rem; margin-top: 1rem;
} }
.ml-8 {
margin-left: 2rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.block { .block {
display: block; display: block;
} }
.inline-block {
display: inline-block;
}
.inline { .inline {
display: inline; display: inline;
} }
@ -839,8 +900,12 @@ select {
display: none; display: none;
} }
.h-6 { .h-24 {
height: 1.5rem; height: 6rem;
}
.h-3 {
height: 0.75rem;
} }
.h-4 { .h-4 {
@ -851,26 +916,34 @@ select {
height: 1.25rem; height: 1.25rem;
} }
.h-6 {
height: 1.5rem;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
.w-6 {
width: 1.5rem;
}
.w-full {
width: 100%;
}
.w-5 { .w-5 {
width: 1.25rem; width: 1.25rem;
} }
.w-6 {
width: 1.5rem;
}
.w-7 { .w-7 {
width: 1.75rem; width: 1.75rem;
} }
.w-auto {
width: auto;
}
.w-full {
width: 100%;
}
.max-w-screen-lg { .max-w-screen-lg {
max-width: 1024px; max-width: 1024px;
} }
@ -883,6 +956,10 @@ select {
max-width: 20rem; max-width: 20rem;
} }
.flex-1 {
flex: 1 1 0%;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@ -913,6 +990,16 @@ select {
justify-content: space-between; justify-content: space-between;
} }
.gap-2 {
gap: 0.5rem;
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.self-center { .self-center {
align-self: center; align-self: center;
} }
@ -927,14 +1014,18 @@ select {
white-space: nowrap; white-space: nowrap;
} }
.rounded { .rounded-full {
border-radius: 0.25rem; border-radius: 9999px;
} }
.rounded-lg { .rounded-lg {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.rounded-sm {
border-radius: 0.125rem;
}
.border-gray-200 { .border-gray-200 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity)); border-color: rgb(229 231 235 / var(--tw-border-opacity));
@ -945,21 +1036,16 @@ select {
border-color: rgb(100 116 139 / var(--tw-border-opacity)); border-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-green-600 { .bg-green-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity)); background-color: rgb(22 163 74 / var(--tw-bg-opacity));
} }
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-violet-600 { .bg-violet-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(124 58 237 / var(--tw-bg-opacity)); background-color: rgb(124 58 237 / var(--tw-bg-opacity));
@ -974,6 +1060,11 @@ select {
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-1 { .py-1 {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
@ -984,6 +1075,10 @@ select {
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
.pb-16 {
padding-bottom: 4rem;
}
.pl-3 { .pl-3 {
padding-left: 0.75rem; padding-left: 0.75rem;
} }
@ -992,8 +1087,12 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.pl-8 { .pt-1 {
padding-left: 2rem; padding-top: 0.25rem;
}
.pt-8 {
padding-top: 2rem;
} }
.text-center { .text-center {
@ -1014,6 +1113,11 @@ select {
line-height: 2.5rem; line-height: 2.5rem;
} }
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-base { .text-base {
font-size: 1rem; font-size: 1rem;
line-height: 1.5rem; line-height: 1.5rem;
@ -1038,8 +1142,14 @@ select {
font-weight: 600; font-weight: 600;
} }
.italic { .text-gray-700 {
font-style: italic; --tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
} }
.text-slate-300 { .text-slate-300 {
@ -1047,6 +1157,11 @@ select {
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
} }
.text-slate-500 {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
.text-white { .text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
@ -1129,7 +1244,13 @@ select {
font-style: normal; font-style: normal;
} }
:is(.dark form label) { a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
}
form label:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
@ -1139,7 +1260,7 @@ select {
margin-right: auto; margin-right: auto;
} }
:is(.dark .responsive-table) { .responsive-table:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
@ -1170,9 +1291,9 @@ select {
border-left-color: rgb(100 116 139 / var(--tw-border-opacity)); border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
:is(.dark form input),:is(.dark form input:is(.dark *),
select),:is(.dark select:is(.dark *),
textarea) { textarea:is(.dark *) {
border-width: 1px; border-width: 1px;
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(15 23 42 / var(--tw-border-opacity)); border-color: rgb(15 23 42 / var(--tw-border-opacity));
@ -1182,6 +1303,28 @@ textarea) {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
form input:disabled:is(.dark *),
select:disabled:is(.dark *),
textarea:disabled:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -1277,16 +1420,44 @@ th label {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.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;
}
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover { .hover\:bg-green-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
} }
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-violet-700:hover { .hover\:bg-violet-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(109 40 217 / var(--tw-bg-opacity)); background-color: rgb(109 40 217 / var(--tw-bg-opacity));
@ -1312,11 +1483,6 @@ th label {
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity)); --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
} }
.focus\:ring-blue-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.focus\:ring-violet-500:focus { .focus\:ring-violet-500:focus {
--tw-ring-opacity: 1; --tw-ring-opacity: 1;
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity)); --tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
@ -1334,27 +1500,36 @@ th label {
--tw-ring-offset-color: #ddd6fe; --tw-ring-offset-color: #ddd6fe;
} }
:is(.dark .dark\:bg-gray-800) { .group:hover .group-hover\:block {
display: block;
}
.dark\:bg-gray-800:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:bg-gray-900) { .dark\:bg-gray-900:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-slate-500) { .dark\:text-slate-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.dark\:text-slate-500:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity)); color: rgb(100 116 139 / var(--tw-text-opacity));
} }
:is(.dark .dark\:text-slate-600) { .dark\:text-slate-600:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity)); color: rgb(71 85 105 / var(--tw-text-opacity));
} }
:is(.dark .dark\:text-white) { .dark\:text-white:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
@ -1381,6 +1556,10 @@ th label {
padding-right: 1rem; padding-right: 1rem;
} }
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 { .sm\:pl-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
} }
@ -1389,6 +1568,10 @@ th label {
padding-left: 1rem; padding-left: 1rem;
} }
.sm\:pl-6 {
padding-left: 1.5rem;
}
.sm\:decoration-2 { .sm\:decoration-2 {
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }

View File

@ -1,29 +1,24 @@
/** import { syncSelectInputUntilChanged } from "./utils.js";
* @description Sync select field with input field until user focuses it.
* @param {HTMLSelectElement} sourceElementSelector
* @param {HTMLInputElement} targetElementSelector
*/
function syncSelectInputUntilChanged(
sourceElementSelector,
targetElementSelector
) {
const sourceElement = document.querySelector(sourceElementSelector);
const targetElement = document.querySelector(targetElementSelector);
function sourceElementHandler(event) {
let selected = event.target.value;
let selectedValue = document.querySelector(
`#id_game option[value='${selected}']`
).textContent;
targetElement.value = selectedValue;
}
function targetElementHandler(event) {
sourceElement.removeEventListener("change", sourceElementHandler);
}
sourceElement.addEventListener("change", sourceElementHandler); let syncData = [
targetElement.addEventListener("focus", targetElementHandler); {
} source: "#id_game",
source_value: "dataset.name",
target: "#id_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "textContent",
target: "#id_sort_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "dataset.year",
target: "#id_year_released",
target_value: "value",
},
];
window.addEventListener("load", () => { syncSelectInputUntilChanged(syncData, "form");
syncSelectInputUntilChanged("#id_game", "#id_name");
});

View 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");

View 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
}
}
});

View File

@ -7,10 +7,14 @@ for (let button of document.querySelectorAll("[data-target]")) {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
if (type == "now") { if (type == "now") {
targetElement.value = toISOUTCString(new Date); targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") { } else if (type == "copy") {
const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start"; const oppositeName =
document.querySelector(`[name='${oppositeName}']`).value = targetElement.value; targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") { } else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text"; if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local"; else targetElement.type = "datetime-local";

View File

@ -3,7 +3,7 @@
* @param {Date} date * @param {Date} date
* @returns {string} * @returns {string}
*/ */
export function toISOUTCString(date) { function toISOUTCString(date) {
function stringAndPad(number) { function stringAndPad(number) {
return number.toString().padStart(2, 0); return number.toString().padStart(2, 0);
} }
@ -14,3 +14,194 @@ export function toISOUTCString(date) {
const minutes = stringAndPad(date.getMinutes()); const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`; return `${year}-${month}-${day}T${hours}:${minutes}`;
} }
/**
* @description Sync values between source and target elements based on syncData configuration.
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
*/
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const parentElement =
parentSelector === document
? document
: document.querySelector(parentSelector);
if (!parentElement) {
console.error(`The parent selector "${parentSelector}" is not valid.`);
return;
}
// Set up a single change event listener on the document for handling all source changes
parentElement.addEventListener("change", function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the change event target matches the source selector
if (event.target.matches(syncItem.source)) {
const sourceElement = event.target;
const valueToSync = getValueFromProperty(
sourceElement,
syncItem.source_value
);
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
targetElement[syncItem.target_value] = valueToSync;
}
}
});
});
// Set up a single focus event listener on the document for handling all target focuses
parentElement.addEventListener(
"focus",
function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the focus event target matches the target selector
if (event.target.matches(syncItem.target)) {
// Remove the change event listener to stop syncing
// This assumes you want to stop syncing once any target receives focus
// You may need a more sophisticated way to remove listeners if you want to stop
// syncing selectively based on other conditions
document.removeEventListener("change", syncSelectInputUntilChanged);
}
});
},
true
); // Use capture phase to ensure the event is captured during focus, not bubble
}
/**
* @description Retrieve the value from the source element based on the provided property.
* @param {Element} sourceElement - The source HTML element.
* @param {string} property - The property to retrieve the value from.
*/
function getValueFromProperty(sourceElement, property) {
let source =
sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0]
: sourceElement;
if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey];
} else if (property in source) {
return source[property];
} else {
console.error(`Property ${property} is not valid for the option element.`);
return null;
}
}
/**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1));
} else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector);
} else {
return document.getElementsByTagName(selector);
}
}
/**
* @description Applies different behaviors to elements based on multiple conditional configurations.
* Each configuration is an array containing a condition function, an array of target element selectors,
* and two callback functions for handling matched and unmatched conditions.
* @param {...Array} configs Each configuration is an array of the form:
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/
function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn2(el);
}
});
}
});
}
function disableElementsWhenValueNotEqual(
targetSelect,
targetValue,
elementList
) {
return conditionalElementHandler([
() => {
let target = getEl(targetSelect);
console.debug(
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
);
console.debug(`
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
if (targetValue instanceof Array) {
if (targetValue.every((value) => target.value != value)) {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return true;
}
} else {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return target.value != targetValue;
}
},
elementList,
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
);
el.disabled = "disabled";
},
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
);
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export {
toISOUTCString,
syncSelectInputUntilChanged,
getEl,
conditionalElementHandler,
disableElementsWhenValueNotEqual,
disableElementsWhenTrue,
getValueFromProperty,
};

View File

@ -1,17 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %} {% block title %}
{{ title }}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }}
{{ form.as_table }} <tr>
<tr> <td></td>
<td></td> <td>
<td><input type="submit" value="Submit"/></td> <input type="submit" value="Submit" />
</tr> </td>
</tr>
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,22 +1,32 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %} {% block title %}
{{ title }}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }}
{{ form.as_table }} <tr>
<tr> <td></td>
<td></td> <td>
<td><input type="submit" value="Submit"/></td> <input type="submit" name="submit" value="Submit" />
</tr> </td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{% load static %} {% if script_name %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script> <script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %} {% endblock scripts %}

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Session" />
</td>
</tr>
{% if purchase_id %}
<tr>
<td></td>
<td>
<a href="{% url 'delete_purchase' purchase_id %}" class="text-red-600" onclick="return confirm('Are you sure you want to delete this purchase?');">Delete</a>
</td>
</tr>
{% endif %}
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,35 +1,38 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{% block title %}{{ title }}{% endblock title %} {{ title }}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{% for field in form %}
{% for field in form %} <tr>
<tr> <th>{{ field.label_tag }}</th>
<th>{{ field.label_tag }}</th> {% if field.name == "note" %}
{% if field.name == "note" %} <td>{{ field }}</td>
<td>{{ field }}</td> {% else %}
{% else %} <td>{{ field }}</td>
<td>{{ field }}</td> {% endif %}
{% endif %} {% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %} <td>
<td> <div class="basic-button-container" hx-boost="false">
<div class="basic-button-container"> <button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button> <button class="basic-button"
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button> data-target="{{ field.name }}"
<button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button> data-type="toggle">Toggle text</button>
</div> <button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
</td> </div>
{% endif %} </td>
</tr> {% endif %}
{% endfor %} </tr>
<tr> {% endfor %}
<td></td> <tr>
<td><input type="submit" value="Submit"/></td> <td></td>
</tr> <td>
<input type="submit" value="Submit" />
</td>
</tr>
</table> </table>
</form> </form>
{% load static %} {% load static %}

View File

@ -1,54 +1,118 @@
<!doctype html> {% load django_htmx %}
<!DOCTYPE html>
<html lang="en"> <html lang="en">
{% load static %} {% load static %}
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8" />
<meta name="description" content="Self-hosted time-tracker."/> <meta name="description" content="Self-hosted time-tracker." />
<meta name="keywords" content="time, tracking, video games, self-hosted"/> <meta name="keywords" content="time, tracking, video games, self-hosted" />
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title> <title>Timetracker -
{% block title %}
Untitled
{% endblock title %}
</title>
<script src="{% static 'js/htmx.min.js' %}"></script> <script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark" hx-indicator="#indicator">
<body class="dark"> <img id="indicator"
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" /> src="{% static 'icons/loading.png' %}"
<div class="dark:bg-gray-800 min-h-screen"> class="absolute right-3 top-3 animate-spin htmx-indicator"
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> height="24"
width="24"
alt="loading indicator" />
<div class="flex flex-col min-h-screen">
<nav class="dark:bg-gray-900 border-gray-200 h-24 flex items-center">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span> <span class="text-4xl">
<img src="{% static 'icons/schedule.png' %}"
height="48"
width="48"
alt="Timetracker Logo"
class="mr-4" />
</span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a> </a>
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul <ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> <li class="relative group">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> {% if user.is_authenticated %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> <a class="block py-2 pl-3 pr-4 hover:underline"
{% if game_available and platform_available %} href="{% url 'add_game' %}">New</a>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li> <ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
{% endif %} {% if purchase_available %}
{% if edition_available %} <li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
{% endif %} href="{% url 'add_device' %}">Device</a>
{% if purchase_available %} </li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> {% endif %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li> <li>
{% endif %} <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
{% if session_count > 0 %} href="{% url 'add_game' %}">Game</a>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> </li>
{% endif %} {% if game_available and platform_available %}
</ul> <li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_edition' %}">Edition</a>
</li>
{% endif %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_platform' %}">Platform</a>
</li>
{% if edition_available %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_purchase' %}">Purchase</a>
</li>
{% endif %}
{% if purchase_available %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_session' %}">Session</a>
</li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'list_sessions' %}">All Sessions</a>
</li>
<li>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'logout' %}">Log Out</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div> </div>
</nav>
<div class="flex flex-1 dark:bg-gray-800 justify-center pt-8 pb-16">
{% block content %}
No content here.
{% endblock content %}
</div> </div>
</nav> {% load version %}
{% block content %}No content here.{% endblock content %} <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div> </div>
{% load version %} {% block scripts %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> {% endblock scripts %}
{% block scripts %}{% endblock scripts %} </body>
</body> </html>
</html>

View File

@ -1,22 +1,13 @@
<button {% comment %}
type="button" title
title="{{ title }}" text
autofocus {% endcomment %}
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg" <a href="{{ link }}"
> title="{{ title }}"
<svg class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm">
xmlns="http://www.w3.org/2000/svg" {% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
fill="none" <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
viewBox="0 0 24 24" </svg>
stroke-width="1.5" {% endcomment %}
stroke="currentColor" {{ text }}
class="self-center w-6 h-6 inline" </a>
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{{ text }}
</button>

View File

@ -0,0 +1,18 @@
{% comment %}
title
text
{% endcomment %}
<button type="button"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
{{ text }}
</button>

View File

@ -1,21 +1,13 @@
<a href="{{ edit_url }}"> <a href="{{ edit_url }}">
<button <button type="button"
type="button" title="Edit"
title="Edit" class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg">
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg" <svg xmlns="http://www.w3.org/2000/svg"
> viewBox="0 0 20 20"
<svg fill="currentColor"
xmlns="http://www.w3.org/2000/svg" class="w-5 h-5">
viewBox="0 0 20 20" <path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
fill="currentColor" <path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
class="w-5 h-5" </svg>
> </button>
<path
d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z"
/>
<path
d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z"
/>
</svg>
</button>
</a> </a>

View File

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{% block title %}{{ title }}{% endblock title %} {{ title }}
{% endblock title %}
{% block content %} {% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center"> <div class="text-slate-300 mx-auto max-w-screen-lg text-center">
{% if session_count > 0 %} {% if session_count > 0 %}

View File

@ -1,77 +1,72 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% block title %}
{% block title %}{{ title }}{% endblock title %} {{ title }}
{% endblock title %}
{% block content %} {% block content %}
<div class="flex-col">
{% if dataset.count >= 1 %} {% if dataset_count >= 1 %}
<div class="mx-auto text-center my-4"> {% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
<a <div class="mx-auto text-center my-4">
id="last-session-start" <a id="last-session-start"
href="{% url 'start_session' last.id %}" href="{{ start_session_url }}"
hx-get="{% url 'start_session' last.id %}" hx-get="{{ start_session_url }}"
hx-indicator="#indicator" hx-swap="afterbegin"
hx-swap="afterbegin" hx-target=".responsive-table tbody"
hx-target=".responsive-table tbody" onClick="document.querySelector('#last-session-start').classList.add('invisible')"
hx-select=".responsive-table tbody tr:first-child" class="{% if last.timestamp_end == null %}invisible{% endif %}">
onClick="document.querySelector('#last-session-start').classList.add('invisible')" {% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
class="{% if last.timestamp_end == null %}invisible{% endif %}" </a>
> </div>
{% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %}
</a>
</div>
{% endif %}
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
</tr>
</thead>
<tbody>
{% for data in dataset %}
<tr>
<td
class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
>
<a
class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' data.purchase.edition.game.id %}">
{{ data.purchase.edition }}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
{{ data.timestamp_start | date:"d/m/Y H:i" }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if data.unfinished %}
<a
href="{% url 'update_session' data.id %}"
hx-get="{% url 'update_session' data.id %}"
hx-swap="outerHTML"
hx-target=".responsive-table tbody tr:first-child"
hx-select=".responsive-table tbody tr:first-child"
hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.remove('invisible')"
>
<span class="text-yellow-300">Finish now?</span>
</a>
{% elif data.duration_manual %}
--
{% else %}
{{ data.timestamp_end | date:"d/m/Y H:i" }}
{% endif %} {% endif %}
</td> {% if dataset_count != 0 %}
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <table class="responsive-table">
{{ data.duration_formatted }} <thead>
</td> <tr>
</tr> <th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
{% endfor %} <th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
</tbody> <th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
</table> <th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
</tr>
</thead>
<tbody>
{% for session in dataset %}
{% partialdef session-row inline=True %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' session.purchase.edition.game.id %}">
{{ session.purchase.edition.name }}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
{{ session.timestamp_start | date:"d/m/Y H:i" }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if not session.timestamp_end %}
{% url 'list_sessions_end_session' session.id as end_session_url %}
<a href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest tr"
hx-swap="outerHTML"
hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
<span class="text-yellow-300">Finish now?</span>
</a>
{% elif session.duration_manual %}
--
{% else %}
{{ session.timestamp_end | date:"d/m/Y H:i" }}
{% endif %}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td>
</tr>
{% endpartialdef %}
{% endfor %}
</tbody>
</table>
{% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
{% endif %}
</div>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1 @@
{{ form.related_purchase }}

View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
Login
{% endblock title %}
{% block content %}
<div class="flex items-center flex-col">
<h2 class="text-3xl text-white mb-8">Please log in to continue</h2>
<form method="post">
<table>
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Login" />
</td>
</tr>
</form>
</table>
</div>
{% endblock content %}

283
games/templates/stats.html Normal file
View File

@ -0,0 +1,283 @@
{% extends "base.html" %}
{% block title %}
{{ title }}
{% endblock title %}
{% load static %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
{% else %}
{{ purchase.edition.name }}
{% endif %}
{% endpartialdef %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year"
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ longest_session_time }} ({{ longest_session_game }})</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ highest_session_count }} ({{ highest_session_count_game }})</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ highest_session_average }} ({{ highest_session_average_game }})
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ first_play_name }} ({{ first_play_date }})</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ last_play_name }} ({{ last_play_date }})</td>
</tr>
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table">
<tbody>
{% for month in month_playtimes %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ dropped_count }} ({{ dropped_percentage }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game.id %}">{{ game.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for item in total_playtime_per_platform %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{% partial purchase-name %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{% partial purchase-name %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{% partial purchase-name %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}

View File

@ -1,82 +1,116 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{% block title %}{{ title }}{% endblock title %} {{ title }}
{% endblock title %}
{% load static %} {% load static %}
{% load markdown_extras %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-4xl flex items-center"> <h1 class="text-5xl flex items-center">
{{ game.name }} {{ game.name }}
<span class="dark:text-slate-500">(#{{ game.pk }})</span>
{% url 'edit_game' game.id as edit_url %} {% url 'edit_game' game.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</h1> </h1>
<h2 class="text-lg my-2 ml-2 dark:text-slate-500">First Released: <span class="text-white">{{ game.year_released }}</span></h2>
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span> <span class="dark:text-slate-500">Playtime: </span>
{{ session_average }} <span class="dark:text-slate-500">avg</span> {{ hours_sum }} <span class="dark:text-slate-500">hours over</span> {{ session_count }} <span class="dark:text-slate-500">sessions (</span>{{ session_average }}<span class="dark:text-slate-500">/session)</span>
({{ first_session.timestamp_start | date:"M Y"}} </h2>
<h2 class="text-lg my-2 ml-2">
{{ last_session.timestamp_start | date:"M Y"}}) </h2> <span class="dark:text-slate-500">Played in: </span>
{{ playrange }}
</h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1> <h1 class="text-3xl mt-4 mb-1">
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1>
<ul> <ul>
{% for edition in editions %} {% for edition in editions %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }}) {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %} {% if edition.wikidata %}
<span class="hidden sm:inline"> <span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}"> <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/> <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" />
</a> </a>
</span>
{% endif %}
{% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul>
{% for purchase in purchases %}
<li class="sm:pl-2 flex items-center">
{{ purchase.platform }}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Sessions <span class="dark:text-slate-500">({{ sessions.count }})</span></h1>
<ul>
{% for session in sessions %}
<li class="sm:pl-2 flex items-center">
{{ session.timestamp_start | date:"d/m/Y" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Notes <span class="dark:text-slate-500">({{ sessions_with_notes.count }})</span></h1>
<ul>
{% for session in sessions_with_notes %}
<li class="sm:pl-2">
<ul>
<li class="block dark:text-slate-500">
<span class="flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
{% url 'edit_session' session.id as edit_session_url %}
{% include 'components/edit_button.html' with edit_url=edit_session_url %}
</span> </span>
</li> {% endif %}
<li class="sm:pl-4 italic"> {% url 'edit_edition' edition.id as edit_url %}
{{ session.note|linebreaks }} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<ul>
{% for purchase in edition.game_purchases %}
<li class="sm:pl-6 flex items-center {% if purchase.date_refunded %}text-red-600{% endif %}">
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
<ul>
{% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-12 flex items-center">
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
{% endfor %}
</ul> </ul>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions
<span class="dark:text-slate-500" id="session-count">({{ session_count }})</span>
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %}
<a
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
title="Start new session"
href="{{ add_session_link }}"
hx-get="{{ add_session_link }}"
hx-vals="js:{session_count:getSessionCount()}"
hx-target="#session-list"
hx-swap="afterbegin"
>New</a>
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1>
<ul id="session-list">
{% for session in sessions %}
{% partialdef session-info inline=True %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1">
{{ session.timestamp_start | date:"d/m/Y H:i" }}{% if session.timestamp_end %}-{{ session.timestamp_end | date:"H:i" }}{% endif %}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
{% if not session.timestamp_end %}
{% url 'view_game_end_session' session.id as end_session_url %}
<a
class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center"
href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest li"
hx-swap="outerHTML"
hx-vals="js:{session_count:getSessionCount()}"
hx-indicator="#indicator"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" class="h-3" x="0px" y="0px" viewBox="0 0 24 24">
<path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z"></path>
</svg>
</a>
{% endif %}
</li>
<li class="sm:pl-4 markdown-content">{{ session.note|markdown }}</li>
<div class="hidden" hx-swap-oob="innerHTML:#session-count">
({{ session_count }})
</div>
{% endpartialdef %}
{% endfor %}
</ul>
</div> </div>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,10 @@
from django import template
from django.utils.safestring import mark_safe
import markdown
register = template.Library()
@register.filter(name="markdown")
def markdown_format(text):
return mark_safe(markdown.markdown(text))

View File

@ -14,22 +14,61 @@ urlpatterns = [
path("add-platform/", views.add_platform, name="add_platform"), path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"), path("add-session/", views.add_session, name="add_session"),
path( path(
"update-session/by-session/<int:session_id>", "add-session-for-purchase/<int:purchase_id>",
views.update_session, views.add_session,
name="update_session", name="add_session_for_purchase",
), ),
path( path(
"start-session/<int:last_session_id>", "session/clone/from-game/<int:session_id>",
views.start_session, views.new_session_from_existing_session,
name="start_session", {"template": "view_game.html#session-info"},
name="view_game_start_session_from_session",
),
path(
"session/clone/from-list/<int:session_id>",
views.new_session_from_existing_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_start_session_from_session",
),
path(
"session/end/from-game/<int:session_id>",
views.end_session,
{"template": "view_game.html#session-info"},
name="view_game_end_session",
),
path(
"session/end/from-list/<int:session_id>",
views.end_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_end_session",
), ),
# path( # path(
# "delete_session/by-id/<int:session_id>", # "delete_session/by-id/<int:session_id>",
# views.delete_session, # views.delete_session,
# name="delete_session", # name="delete_session",
# ), # ),
path(
"purchase/<int:purchase_id>/delete",
views.delete_purchase,
name="delete_purchase",
),
path("add-purchase/", views.add_purchase, name="add_purchase"), path("add-purchase/", views.add_purchase, name="add_purchase"),
path(
"add-purchase-for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"), path("add-edition/", views.add_edition, name="add_edition"),
path(
"add-edition-for-game/<int:game_id>",
views.add_edition,
name="add_edition_for_game",
),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"), path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
path("game/<int:game_id>/view", views.view_game, name="view_game"), path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"), path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
@ -68,4 +107,10 @@ urlpatterns = [
{"filter": "ownership_type"}, {"filter": "ownership_type"},
name="list_sessions_by_ownership_type", name="list_sessions_by_ownership_type",
), ),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
] ]

View File

@ -1,60 +1,111 @@
from datetime import datetime, timedelta from datetime import datetime
from zoneinfo import ZoneInfo from typing import Any, Callable
from common.time import now as now_with_tz from django.contrib.auth.decorators import login_required
from common.time import format_duration
from django.conf import settings from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F,
Prefetch,
Q,
Sum,
fields,
IntegerField,
)
from django.db.models.functions import TruncDate, ExtractMonth, TruncMonth
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
from django.shortcuts import get_object_or_404
from common.time import format_duration
from common.utils import safe_division
from .forms import ( from .forms import (
DeviceForm,
EditionForm,
GameForm, GameForm,
PlatformForm, PlatformForm,
PurchaseForm, PurchaseForm,
SessionForm, SessionForm,
EditionForm,
DeviceForm,
) )
from .models import Game, Platform, Purchase, Session, Edition from .models import Edition, Game, Platform, Purchase, Session
def model_counts(request): def model_counts(request):
return { return {
"game_available": Game.objects.count() != 0, "game_available": Game.objects.exists(),
"edition_available": Edition.objects.count() != 0, "edition_available": Edition.objects.exists(),
"platform_available": Platform.objects.count() != 0, "platform_available": Platform.objects.exists(),
"purchase_available": Purchase.objects.count() != 0, "purchase_available": Purchase.objects.exists(),
"session_count": Session.objects.count(), "session_count": Session.objects.exists(),
} }
def add_session(request): def stats_dropdown_year_range(request):
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
return result
@login_required
def add_session(request, purchase_id=None):
context = {} context = {}
initial = {} initial = {"timestamp_start": timezone.now()}
now = now_with_tz() last = Session.objects.last()
initial["timestamp_start"] = now
last = Session.objects.all().last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["purchase"] = last.purchase
form = SessionForm(request.POST or None, initial=initial) if request.method == "POST":
if form.is_valid(): form = SessionForm(request.POST or None, initial=initial)
form.save() if form.is_valid():
return redirect("list_sessions") form.save()
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
}
)
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session" context["title"] = "Add New Session"
context["form"] = form context["form"] = form
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
def update_session(request, session_id=None): def use_custom_redirect(
session = Session.objects.get(id=session_id) func: Callable[..., HttpResponse]
session.finish_now() ) -> Callable[..., HttpResponse]:
session.save() """
return redirect("list_sessions") Will redirect to "return_path" session variable if set.
"""
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = func(request, *args, **kwargs)
if isinstance(response, HttpResponseRedirect) and (
next_url := request.session.get("return_path")
):
return HttpResponseRedirect(next_url)
return response
return wrapper
@login_required
@use_custom_redirect
def edit_session(request, session_id=None): def edit_session(request, session_id=None):
context = {} context = {}
session = Session.objects.get(id=session_id) session = Session.objects.get(id=session_id)
@ -67,6 +118,8 @@ def edit_session(request, session_id=None):
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
@login_required
@use_custom_redirect
def edit_purchase(request, purchase_id=None): def edit_purchase(request, purchase_id=None):
context = {} context = {}
purchase = Purchase.objects.get(id=purchase_id) purchase = Purchase.objects.get(id=purchase_id)
@ -76,9 +129,13 @@ def edit_purchase(request, purchase_id=None):
return redirect("list_sessions") return redirect("list_sessions")
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
return render(request, "add.html", context) context["purchase_id"] = purchase_id
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@login_required
@use_custom_redirect
def edit_game(request, game_id=None): def edit_game(request, game_id=None):
context = {} context = {}
purchase = Game.objects.get(id=game_id) purchase = Game.objects.get(id=game_id)
@ -91,31 +148,66 @@ def edit_game(request, game_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
def view_game(request, game_id=None): def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
context["title"] = "View Game" nongame_related_purchases_prefetch = Prefetch(
context["game"] = game "related_purchases",
context["editions"] = Edition.objects.filter(game_id=game_id) queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by(
context["purchases"] = Purchase.objects.filter(edition__game_id=game_id) "date_purchased"
context["sessions"] = Session.objects.filter( ),
purchase__edition__game_id=game_id to_attr="nongame_related_purchases",
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
) )
context["session_average"] = round( game_purchases_prefetch = Prefetch(
(context["total_hours"]) / int(context["sessions"].count()), 1 "purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
) )
# here first and last is flipped editions = (
# because sessions are ordered from newest to oldest Edition.objects.filter(game=game)
# so the most recent are on top .prefetch_related(game_purchases_prefetch)
context["last_session"] = context["sessions"].first() .order_by("year_released")
context["first_session"] = context["sessions"].last() )
context["sessions_with_notes"] = context["sessions"].exclude(note="")
sessions = Session.objects.prefetch_related("device").filter(
purchase__edition__game=game
)
session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest()
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"latest_session_id": latest_session.pk,
}
request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@login_required
@use_custom_redirect
def edit_platform(request, platform_id=None): def edit_platform(request, platform_id=None):
context = {} context = {}
purchase = Platform.objects.get(id=platform_id) purchase = Platform.objects.get(id=platform_id)
@ -128,6 +220,8 @@ def edit_platform(request, platform_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
@use_custom_redirect
def edit_edition(request, edition_id=None): def edit_edition(request, edition_id=None):
context = {} context = {}
edition = Edition.objects.get(id=edition_id) edition = Edition.objects.get(id=edition_id)
@ -140,16 +234,53 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
def start_session(request, last_session_id: int): def related_purchase_by_edition(request):
last_session = Session.objects.get(id=last_session_id) edition_id = request.GET.get("edition")
session = SessionForm( if not edition_id:
{ return HttpResponseBadRequest("Invalid edition_id")
"purchase": last_session.purchase.id, form = PurchaseForm()
"timestamp_start": now_with_tz(), form.fields["related_purchase"].queryset = Purchase.objects.filter(
"device": last_session.device, edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
def clone_session_by_id(session_id: int) -> Session:
session = get_object_or_404(Session, id=session_id)
clone = session
clone.pk = None
clone.timestamp_start = timezone.now()
clone.timestamp_end = None
clone.note = ""
clone.save()
return clone
@login_required
@use_custom_redirect
def new_session_from_existing_session(request, session_id: int, template: str = ""):
session = clone_session_by_id(session_id)
if request.htmx:
context = {
"session": session,
"session_count": int(request.GET.get("session_count", 0)) + 1,
} }
) return render(request, template, context)
return redirect("list_sessions")
@login_required
@use_custom_redirect
def end_session(request, session_id: int, template: str = ""):
session = get_object_or_404(Session, id=session_id)
session.timestamp_end = timezone.now()
session.save() session.save()
if request.htmx:
context = {
"session": session,
"session_count": request.GET.get("session_count", 0),
}
return render(request, template, context)
return redirect("list_sessions") return redirect("list_sessions")
@ -159,6 +290,7 @@ def start_session(request, last_session_id: int):
# return redirect("list_sessions") # return redirect("list_sessions")
@login_required
def list_sessions( def list_sessions(
request, request,
filter="", filter="",
@ -171,85 +303,391 @@ def list_sessions(
context = {} context = {}
context["title"] = "Sessions" context["title"] = "Sessions"
all_sessions = Session.objects.prefetch_related(
"purchase", "purchase__edition", "purchase__edition__game"
).order_by("-timestamp_start")
if filter == "purchase": if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id) dataset = all_sessions.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id) context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform": elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id) dataset = all_sessions.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id) context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition": elif filter == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id) dataset = all_sessions.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id) context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game": elif filter == "game":
dataset = Session.objects.filter(purchase__edition__game=game_id) dataset = all_sessions.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id) context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type": elif filter == "ownership_type":
dataset = Session.objects.filter(purchase__ownership_type=ownership_type) dataset = all_sessions.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent": elif filter == "recent":
current_year = datetime.now().year current_year = timezone.now().year
first_day_of_year = datetime(current_year, 1, 1) first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = Session.objects.filter( dataset = all_sessions.filter(timestamp_start__gte=first_day_of_year).order_by(
timestamp_start__gte=first_day_of_year "-timestamp_start"
).order_by("-timestamp_start") )
context["title"] = "This year" context["title"] = "This year"
else: else:
# by default, sort from newest to oldest dataset = all_sessions
dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset: context = {
if session.timestamp_end == None and session.duration_manual == timedelta( **context,
seconds=0 "dataset": dataset,
): "dataset_count": dataset.count(),
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) "last": Session.objects.prefetch_related("purchase__platform").latest(),
session.unfinished = True }
context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last()
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
def add_purchase(request): @login_required
def stats(request, year: int = 0):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = timezone.now().year
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).select_related("purchase__edition")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"edition__purchase__session",
filter=Q(edition__purchase__session__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(date_finished__isnull=True)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc.
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=True
)
)
this_year_purchases_dropped = (
this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=False
)
)
this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count()
)
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished_count,
this_year_purchases_without_refunded_count,
)
* 100
)
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished"
)
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(date_finished__year=year)
).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_calculated"))
.order_by("month")
)
for month in month_playtimes:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchase__session__duration_calculated")
)
.order_by("-session_average")
.first()
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year)
.intersection(purchases_finished_this_year)
.count()
)
first_play_name = "N/A"
first_play_date = "N/A"
last_play_name = "N/A"
last_play_date = "N/A"
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_name = first_session.purchase.edition.name
first_play_date = first_session.timestamp_start.strftime("%x")
last_session = this_year_sessions.latest()
last_play_name = last_session.purchase.edition.name
last_play_date = last_session.timestamp_start.strftime("%x")
all_purchased_this_year_count = this_year_purchases_with_currency.count()
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
* 100
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
edition__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"all_finished_this_year": purchases_finished_this_year.select_related(
"edition"
).order_by("date_finished"),
"all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
"edition"
).order_by(
"date_finished"
),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related(
"edition"
).order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"dropped_count": this_year_purchases_dropped_count,
"dropped_percentage": this_year_purchases_dropped_percentage,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year_count,
all_purchased_this_year_count,
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
"date_purchased"
),
"all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count,
"longest_session_time": (
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
if longest_session
else 0
),
"longest_session_game": (
longest_session.purchase.edition.name if longest_session else "N/A"
),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
else 0
),
"highest_session_count_game": (
game_highest_session_count.name if game_highest_session_count else "N/A"
),
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game,
"first_play_name": first_play_name,
"first_play_date": first_play_date,
"last_play_name": last_play_name,
"last_play_date": last_play_date,
"title": f"{year} Stats",
"month_playtimes": month_playtimes,
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
@login_required
def delete_purchase(request, purchase_id=None):
purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.delete()
return redirect("list_sessions")
@login_required
def add_purchase(request, edition_id=None):
context = {} context = {}
now = datetime.now() initial = {"date_purchased": timezone.now()}
initial = {"date_purchased": now}
form = PurchaseForm(request.POST or None, initial=initial) if request.method == "POST":
if form.is_valid(): form = PurchaseForm(request.POST or None, initial=initial)
form.save() if form.is_valid():
return redirect("index") purchase = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
)
)
else:
return redirect("index")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
}
)
else:
form = PurchaseForm(initial=initial)
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
return render(request, "add.html", context) context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@login_required
def add_game(request): def add_game(request):
context = {} context = {}
form = GameForm(request.POST or None) form = GameForm(request.POST or None)
if form.is_valid(): if form.is_valid():
form.save() game = form.save()
return redirect("index") if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("index")
context["form"] = form context["form"] = form
context["title"] = "Add New Game" context["title"] = "Add New Game"
return render(request, "add.html", context) context["script_name"] = "add_game.js"
return render(request, "add_game.html", context)
def add_edition(request): @login_required
def add_edition(request, game_id=None):
context = {} context = {}
form = EditionForm(request.POST or None) if request.method == "POST":
if form.is_valid(): form = EditionForm(request.POST or None)
form.save() if form.is_valid():
return redirect("index") edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = Game.objects.get(id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form context["form"] = form
context["title"] = "Add New Edition" context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context) return render(request, "add_edition.html", context)
@login_required
def add_platform(request): def add_platform(request):
context = {} context = {}
form = PlatformForm(request.POST or None) form = PlatformForm(request.POST or None)
@ -262,6 +700,7 @@ def add_platform(request):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
def add_device(request): def add_device(request):
context = {} context = {}
form = DeviceForm(request.POST or None) form = DeviceForm(request.POST or None)
@ -274,5 +713,6 @@ def add_device(request):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
def index(request): def index(request):
return redirect("list_sessions_recent") return redirect("list_sessions_recent")

1029
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +1,37 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.1.2" version = "1.5.2"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.dependencies] [tool.poetry.group.main.dependencies]
python = "^3.10" python = "^3.11"
django = "^4.1.4" django = "^4.2.0"
gunicorn = "^20.1.0" gunicorn = "^22"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
graphene-django = "^3.1.5"
django-htmx = "^1.17.2"
django-template-partials = "^23.4"
markdown = "^3.5.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^24.3.0"
mypy = "^0.991" mypy = "^1.9.0"
pyyaml = "^6.0" pyyaml = "^6.0"
pytest = "^7.2.0" pytest = "^8.1.1"
django-extensions = "^3.2.1" django-extensions = "^3.2.1"
werkzeug = "^2.2.2" djhtml = "^3.0.6"
djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4" isort = "^5.11.4"
pre-commit = "^3.5.0"
django-debug-toolbar = "^4.2.0"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

35
tests/test_graphql.py Normal file
View File

@ -0,0 +1,35 @@
import json
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.test import TestCase
from graphene_django.utils.testing import GraphQLTestCase
from games import schema
from games.models import Game
class GameAPITestCase(GraphQLTestCase):
GRAPHENE_SCHEMA = schema.schema
def test_query_all_games(self):
response = self.query(
"""
query {
games {
id
name
}
}
"""
)
self.assertResponseNoErrors(response)
self.assertEqual(
len(json.loads(response.content)["data"]["games"]),
Game.objects.count(),
)

View File

@ -0,0 +1,90 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.test import TestCase
from django.urls import reverse
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class PathWorksTest(TestCase):
def setUp(self) -> None:
pl = Platform(name="Test Platform")
pl.save()
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition", platform=pl)
e.save()
p = Purchase(
edition=e,
platform=pl,
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.testSession = s
return super().setUp()
def test_add_device_returns_200(self):
url = reverse("add_device")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_platform_returns_200(self):
url = reverse("add_platform")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_game_returns_200(self):
url = reverse("add_game")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_edition_returns_200(self):
url = reverse("add_edition")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_purchase_returns_200(self):
url = reverse("add_purchase")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_session_returns_200(self):
url = reverse("add_session")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_session_returns_200(self):
id = self.testSession.id
url = reverse("edit_session", args=[id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_view_game_returns_200(self):
url = reverse("view_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_game_returns_200(self):
url = reverse("edit_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_list_sessions_returns_200(self):
url = reverse("list_sessions")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View File

@ -0,0 +1,40 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class FormatDurationTest(TestCase):
def setUp(self) -> None:
return super().setUp()
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition")
e.save()
p = Purchase(
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.assertEqual(
s.duration_formatted(),
"02:40",
)

View File

@ -6,7 +6,6 @@ from common.time import format_duration
class FormatDurationTest(unittest.TestCase): class FormatDurationTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
return super().setUp() return super().setUp()
def test_only_days(self): def test_only_days(self):
@ -19,6 +18,21 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
self.assertEqual(result, "1 hours") self.assertEqual(result, "1 hours")
def test_only_hours_fractional(self):
delta = timedelta(hours=1)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "1.0 hours")
def test_less_than_hour_with_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "0.5 hours")
def test_less_than_hour_without_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%H hours")
self.assertEqual(result, "0 hours")
def test_overflow_hours(self): def test_overflow_hours(self):
delta = timedelta(hours=25) delta = timedelta(hours=25)
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
@ -69,6 +83,16 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%r seconds") result = format_duration(delta, "%r seconds")
self.assertEqual(result, "0 seconds") self.assertEqual(result, "0 seconds")
def test_specific(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%H:%m")
self.assertEqual(result, "2:40")
def test_specific_precise_if_unncessary(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%02.0H:%02.0m")
self.assertEqual(result, "02:40")
def test_all_at_once(self): def test_all_at_once(self):
delta = timedelta(days=50, hours=10, minutes=34, seconds=24) delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
result = format_duration( result = format_duration(

View File

@ -38,11 +38,17 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"template_partials",
"graphene_django",
"django_htmx",
] ]
# if DEBUG: GRAPHENE = {"SCHEMA": "games.schema.schema"}
INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin") if DEBUG:
INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin")
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
@ -52,9 +58,18 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
] ]
if DEBUG:
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]
DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
ROOT_URLCONF = "timetracker.urls" ROOT_URLCONF = "timetracker.urls"
LOGIN_URL = "/login/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/login/"
TEMPLATES = [ TEMPLATES = [
{ {
@ -68,7 +83,9 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range",
], ],
"builtins": ["template_partials.templatetags.partials"],
}, },
}, },
] ]
@ -122,7 +139,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@ -149,5 +166,3 @@ if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",") CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else: else:
CSRF_TRUSTED_ORIGINS = [] CSRF_TRUSTED_ORIGINS = []
USE_L10N = False

View File

@ -15,14 +15,20 @@ Including another URLconf
""" """
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import RedirectView from django.views.generic import RedirectView
from graphene_django.views import GraphQLView
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker")), path("", RedirectView.as_view(url="/tracker")),
path("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
path("tracker/", include("games.urls")), path("tracker/", include("games.urls")),
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
] ]
# if settings.DEBUG: if settings.DEBUG:
# urlpatterns.append(path("admin/", admin.site.urls)) urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("admin/", admin.site.urls)) urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))