176 Commits

Author SHA1 Message Date
d6f77c0c19 Add django-cors-headers
Some checks reported warnings
Django CI/CD / build-and-push (pull_request) Has been cancelled
2023-11-18 09:23:54 +01:00
52881a88c6 Remove top-level package.json 2023-11-18 09:23:54 +01:00
76b09fea39 Integrate TailwindCSS 2023-11-18 09:23:54 +01:00
89f6959fc1 Change API url 2023-11-18 09:23:10 +01:00
77d6ad2618 Add django-rest-framework 2023-11-18 09:23:09 +01:00
125d17da8a Merge sessions and notes 2023-11-18 09:22:26 +01:00
38d416c4f3 Don't display prices if zero 2023-11-18 09:22:26 +01:00
63bb8e5fa6 Apply djlint 2023-11-18 09:22:26 +01:00
71ede4e4dd Add pre-commit 2023-11-18 09:22:26 +01:00
12159e00c1 Use the black profile for isort 2023-11-18 09:22:26 +01:00
f4bdb0a3e2 Use isort on migrations 2023-11-18 09:22:26 +01:00
583efe1754 Handle empty edition_id 2023-11-18 09:22:26 +01:00
0f5067d66d Prevent HTMX from messing up the initial state 2023-11-18 09:22:26 +01:00
a32d6c38ab Account for no sessions 2023-11-18 09:22:26 +01:00
4f67bd85c7 Enable hx-boost everywhere 2023-11-18 09:22:26 +01:00
cabcaebead Fix form not syncing due to HTMX 2023-11-18 09:22:26 +01:00
047f67c4cb Fix error 2023-11-18 09:22:26 +01:00
34118e1319 Re-instance gitea actions 2023-11-18 09:22:26 +01:00
bd07f19939 Update .drone.yml testing 2023-11-18 09:22:26 +01:00
78a50739a8 Formatting 2023-11-18 09:22:26 +01:00
a579ef7d7d Move static files in prod 2023-11-18 09:22:26 +01:00
8ab2dcc950 Fix docker-compose.yml 2023-11-18 09:22:26 +01:00
ee4efae594 Improve Dockerfile
Major inspiration (aka direct theft) from https://github.com/wemake-services/wemake-django-template
2023-11-18 09:22:26 +01:00
b3eed84e1b Fix .dockerignore 2023-11-18 09:22:26 +01:00
eb53a5983c Remove Django admin 2023-11-18 09:22:26 +01:00
023736d544 Update dependencies 2023-11-18 09:22:26 +01:00
4a990bb6c6 Fix naive date 2023-11-18 09:22:26 +01:00
af85607ada isort 2023-11-18 09:22:26 +01:00
978714125b 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-18 09:22:26 +01:00
51ba5cfa20 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-18 09:22:26 +01:00
a7dd0c5556 Only allow choosing purchases of selected edition 2023-11-18 09:22:26 +01:00
7b2dba7483 Name and related_purchase validation for non-games 2023-11-18 09:22:26 +01:00
250fa0ee9d Sort imports, remove cruft 2023-11-18 09:22:26 +01:00
952961a3ad Fix wrong playrange ordering 2023-11-18 09:22:25 +01:00
1f9b3d5682 Improve how editions and purchases are displayed 2023-11-18 09:22:25 +01:00
c8e8ba8c8e Game View: order editions by year 2023-11-18 09:22:25 +01:00
ab9c1d61dd Version 1.5.1 2023-11-18 09:22:25 +01:00
d64ad125ff Improve and cleanup ConditionalElementHandler 2023-11-18 09:22:25 +01:00
410f20acea Improve purchase __str__ 2023-11-18 09:22:25 +01:00
d280fa3798 Disallow choosing non-game purchase as related purchase 2023-11-18 09:22:25 +01:00
29d6cb8a4a Version 1.5.0 2023-11-18 09:22:25 +01:00
c4fdefadb0 Order purchases by date on game view 2023-11-18 09:22:25 +01:00
7ba212e718 Add purchase types 2023-11-18 09:22:25 +01:00
7b459a2735 CI: run migrations before tests 2023-11-18 09:22:25 +01:00
f0822ff221 Make sure empty stats are 0 2023-11-18 09:22:25 +01:00
200819ceb0 Change stats years to 2000 up to current year 2023-11-18 09:22:25 +01:00
8a433616c4 Add stat for finished this year's games 2023-11-18 09:22:25 +01:00
809e8e2d7c Fix detecting manual durations 2023-11-18 09:22:25 +01:00
528b7d78a1 Remove deprecated USE_L10N 2023-11-18 09:22:25 +01:00
5270525fc2 Add more tests 2023-11-18 09:22:25 +01:00
09faa8e6fd Fix edge case in format_duration
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-18 09:22:25 +01:00
87f50cedd2 Fix ordering 2023-11-18 09:22:25 +01:00
cbf872d21f Also prefill year between game and edition 2023-11-18 09:22:25 +01:00
881e5df3d1 Version 1.4.0 2023-11-18 09:22:25 +01:00
fa6ac21b42 Adding new games is easier 2023-11-18 09:22:25 +01:00
01ce582b74 Refactor, remove cruft 2023-11-18 09:22:25 +01:00
827279fb3d Add backlog decrease count 2023-11-18 09:22:25 +01:00
fa2b9b5c88 Fix sort names getting mangled 2023-11-18 09:22:25 +01:00
55ff3cd1a9 Pre-fill year when adding edition 2023-11-18 09:22:25 +01:00
68d1bfc2b9 UX improvements
* 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-18 09:22:25 +01:00
4112d593f6 Fix hardcoded year 2023-11-18 09:22:25 +01:00
553030c08e Order devices alphabetically on new session form 2023-11-18 09:22:25 +01:00
06a4319623 Use safe_division in more places 2023-11-18 09:22:25 +01:00
cb99d8b656 Fix potential division by zero 2023-11-18 09:22:25 +01:00
b8df870cca Add more stats
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-18 09:22:25 +01:00
7781efd849 Add more stats
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-18 09:22:25 +01:00
9fa95758bf stats: change overall stats table layout 2023-11-18 09:22:25 +01:00
b81989a352 Model changes
* 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-18 09:22:25 +01:00
5e6a5a4024 simplify playtime range display 2023-11-18 09:22:25 +01:00
288bf65afb Version 1.3.0 2023-11-18 09:22:25 +01:00
1d0bafd72d Make some pages redirect back instead to session list 2023-11-18 09:22:25 +01:00
db9ac2edeb Add more stats 2023-11-18 09:22:25 +01:00
2076fe20d1 Extend stats range to 2018 2023-11-18 09:22:25 +01:00
ff30962ce9 Remove cruft 2023-11-18 09:22:25 +01:00
d06e284426 Group by game instead of purchase 2023-11-18 09:22:25 +01:00
1f36a32b3d Reorder imports in views.py 2023-11-18 09:22:25 +01:00
e87f985cb3 Remove hardcoded year 2023-11-18 09:22:25 +01:00
8ca62f32e3 Make navigation more compact 2023-11-18 09:22:25 +01:00
5bc04c463a Add stats link, year selector 2023-11-18 09:22:25 +01:00
0130ab0059 Limit stats of single year correctly 2023-11-18 09:22:25 +01:00
df5e605582 Version 1.2.0 2023-11-18 09:22:25 +01:00
ade8a47c86 Add yearly stats page
Fixes #15
2023-11-18 09:22:25 +01:00
4dd6f13160 Add a button to start session from game overview
Fix #62
2023-11-18 09:22:25 +01:00
50f0571144 Add more time tests for fractional numbers 2023-11-18 09:22:25 +01:00
3fae309ff2 Do not format as float if no precision specified 2023-11-18 09:22:25 +01:00
e0a4aae6f6 Version 1.1.2 2023-11-18 09:22:25 +01:00
e1848caee9 Display durations in a consistent manner
Fixes #61
2023-11-18 09:22:25 +01:00
4767c17004 Version 1.1.1 2023-11-18 09:22:25 +01:00
2cbe10a983 Remove debugging cruft from container 2023-11-18 09:22:25 +01:00
693e1b1cd3 Add edit buttons to game overview, notes 2023-11-18 09:22:24 +01:00
8eeeb9d3c9 Version 1.1.0 2023-11-18 09:22:24 +01:00
caa2ae06e1 Improve game overview
- add counts for each section
- add average hours per session
2023-11-18 09:22:24 +01:00
b9869cf232 Fix playtime range not working with manual session
Fixes #55
2023-11-18 09:22:24 +01:00
36f80b7f01 Remove unused chart functionality 2023-11-18 09:22:24 +01:00
9df959d989 ci: automatically redeploy container 2023-11-18 09:21:40 +01:00
db7b60bdf2 Organize changelog better 2023-11-18 09:21:40 +01:00
470426004e Fix session being wrongly considered in progress
Fixes #58
2023-11-18 09:21:40 +01:00
3e04ef2b83 Fix date range on game overview 2023-11-18 09:21:40 +01:00
760f12e6b1 Adjust game edit URL 2023-11-18 09:21:40 +01:00
ac1f86cac3 Add game overview page
Fixes #8
2023-11-18 09:21:40 +01:00
775ec6424b Try to shutdown container gracefully and faster 2023-11-18 09:21:40 +01:00
6000384c72 Switch fonts to WOFF2 2023-11-18 09:21:40 +01:00
8786a4518e Further improve session list 2023-11-18 09:21:34 +01:00
a2ababaebc Update base.css 2023-11-18 09:21:24 +01:00
aae09a913c Update .vscode 2023-11-18 09:21:24 +01:00
c07f966cf1 Update CSS 2023-11-18 09:21:24 +01:00
d653f83a33 Improve session list
Fixes #53
2023-11-18 09:21:24 +01:00
552d4bee9e Focus important fields on forms 2023-11-18 09:20:54 +01:00
8293737058 Update generated CSS 2023-11-18 09:20:54 +01:00
5234e19705 Add hacky way to not reload page when starting/ending session
Partially fixes #52
2023-11-18 09:20:54 +01:00
077d8d1e3c Add time copy button, improve session editing 2023-11-18 09:20:54 +01:00
8ba5344a4d Fix bug when filtering only manual sessions (#51) 2023-11-18 09:20:54 +01:00
9d2a0ace33 add robots.txt 2023-11-18 09:20:54 +01:00
9b1f4e95e0 disable deleting sessions in code 2023-11-18 09:20:54 +01:00
d1f843a267 allow admin in prod 2/2 2023-11-18 09:20:54 +01:00
65a110b37c allow admin in prod 2023-11-18 09:20:54 +01:00
bbb5d0bfd5 disable deleting sessions 2023-11-18 09:20:54 +01:00
61c3b02472 install dev dependecies 2023-11-18 09:20:54 +01:00
4355953e42 do not filter out refunded games when adding session 2023-11-18 09:20:54 +01:00
1610b7d8a4 allow django admit 2023-11-18 09:20:54 +01:00
a9c3e93954 Change recent session view to current year 2023-11-18 09:20:54 +01:00
fb8fea4724 input datetime-local needs day to also be two digits 2023-11-18 09:20:54 +01:00
11b19cbace Exclude refunded purchases from start session form 2023-11-18 09:20:54 +01:00
fd9d531baf Update styles 2023-11-18 09:20:54 +01:00
3c02e3beaa Add .editorconfig 2023-11-18 09:20:54 +01:00
2b9f321edf Improve forms, add helper buttons on add session form 2023-11-18 09:20:54 +01:00
ee44c52324 Register Edition, Device in the admin UI 2023-11-18 09:20:54 +01:00
b30679f6e7 Use date and datetime inputs
Properly implements 4d91a76513
2023-11-18 09:20:54 +01:00
42d002a9d9 Sort games by name on edition form 2023-11-18 09:20:24 +01:00
56ffde8402 Sync game name and edition name fields for QOL 2023-11-18 09:20:24 +01:00
94706595c5 Fix gitignoring root static folder 2023-11-18 09:20:24 +01:00
352f6133d8 Sort games alphabetically on edition form 2023-11-18 09:20:24 +01:00
8f1bbab895 Version 1.0.3 2023-11-18 09:20:24 +01:00
38b25ff27e Allow editing filtered entities from session list 2023-11-18 09:20:24 +01:00
4086c078bd Improve display of editions on purchase form 2023-11-18 09:20:23 +01:00
c1023bdcbc Sort platforms alphabetically on edition form 2023-11-18 09:20:23 +01:00
115a2a0844 Add wikidata ID and year for editions 2023-11-18 09:20:23 +01:00
326e4aac60 Add icons 2023-11-18 09:20:23 +01:00
fa4f256cb8 Change timetracker icon 2023-11-18 09:20:23 +01:00
739a41c4aa Add icons for session filters 2023-11-18 09:20:23 +01:00
376beea02f Fix error when generating charts with less than 2 entries 2023-11-18 09:20:23 +01:00
5e54871374 Allow filtering by game, edition, purchase from the session list 2023-11-18 09:20:23 +01:00
42e9ba15c5 Fix form styling 2023-11-18 09:20:23 +01:00
2680476fe8 Version 1.0.2 2023-11-18 09:20:11 +01:00
a05e0e0fa3 Allow editing editions 2023-11-18 09:20:11 +01:00
d2455ec82a Allow editing purchases 2023-11-18 09:20:11 +01:00
f0e307dcfb Fix session starting 2023-11-18 09:20:11 +01:00
1a6e5dc8fc Fix ownership display 2023-11-18 09:20:11 +01:00
f8844ce091 Add support for device info
Closes #49
2023-11-18 09:20:11 +01:00
5efb054c30 Add support for purchase ownership information
Closes #48
2023-11-18 09:20:11 +01:00
622c52bb20 Add support for prices on purchases 2023-11-18 09:20:11 +01:00
d447302b46 Re-introduce index view 2023-11-18 09:20:11 +01:00
b0b3567bf3 Fix displaying of "filterying by..." text 2023-11-18 09:20:11 +01:00
812f74d79f Add support for game editions (#28) 2023-11-18 09:20:11 +01:00
7974b74cd4 Fix make css 2023-11-18 09:20:11 +01:00
c61891cff4 Fix redirect to index 2023-11-18 09:19:59 +01:00
6790c434f7 Order homepage sessions from newest 2023-11-18 09:19:59 +01:00
b45889fd85 Version 1.0.1 2023-11-18 09:19:59 +01:00
791203c2dc Show only last 30 days on homepage
Fixes #47
2023-11-18 09:19:59 +01:00
8918df1dfd Show markers on smaller graphs 2023-11-18 09:19:42 +01:00
c1b1cda38e Make it possible to edit sessions
Fixes #46
2023-11-18 09:19:42 +01:00
345315db38 Start converting to react-router
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 17:50:15 +01:00
d9076e4367 Add apiService 2023-01-25 17:49:02 +01:00
65e556656f Fix Vite proxy config 2023-01-25 17:48:51 +01:00
22fd1c356a Add tailwind typography and forms 2023-01-25 17:48:33 +01:00
9080425e1e Add django-cors-headers 2023-01-25 17:48:17 +01:00
c64c68791f Add eslint and prettier 2023-01-25 13:31:05 +01:00
f6edc8688a Remove cruft 2023-01-25 13:19:50 +01:00
1c73bba301 Remove top-level package.json 2023-01-25 13:07:45 +01:00
da5783c08f Add Nav component 2023-01-25 12:56:32 +01:00
04e6b01c2a Integrate TailwindCSS 2023-01-25 12:47:39 +01:00
3a14ea98a6 Change API url 2023-01-25 12:17:56 +01:00
ba27511b97 Add django-rest-framework 2023-01-25 12:05:37 +01:00
ae95015f55 CI: autotag
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 11:49:22 +01:00
c217fd30ef Add vite proxy config 2023-01-25 11:48:17 +01:00
ec16d40361 Add vite react
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-25 11:44:04 +01:00
210 changed files with 3977 additions and 9973 deletions

View File

@ -1,25 +0,0 @@
{
"name": "Django Time Tracker",
"dockerFile": "../devcontainer.Dockerfile",
"customizations": {
"vscode": {
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.defaultInterpreterPath": "/usr/local/bin/python",
"terminal.integrated.defaultProfile.linux": "bash"
},
"extensions": [
"ms-python.python",
"ms-python.debugpy",
"ms-python.vscode-pylance",
"ms-azuretools.vscode-docker",
"batisteo.vscode-django",
"charliermarsh.ruff",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}
},
"forwardPorts": [8000],
"postCreateCommand": "poetry install && poetry run python manage.py migrate && npm install && make dev",
}

View File

@ -30,9 +30,7 @@ steps:
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
auto_tag: true
when:
branch:
exclude:

View File

@ -15,6 +15,3 @@ indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2
[*.html]
insert_final_newline = false

1
.envrc
View File

@ -1 +0,0 @@
use nix

View File

@ -0,0 +1,27 @@
name: Django CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

View File

@ -1,36 +0,0 @@
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,12 +1,9 @@
__pycache__
.mypy_cache
.pytest_cache
.venv/
.venv
node_modules
package-lock.json
db.sqlite3
/static/
dist/
.DS_Store
.python-version
.direnv

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

@ -0,0 +1,10 @@
repos:
- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)

View File

@ -1,11 +0,0 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
"batisteo.vscode-django",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}

26
.vscode/launch.json vendored
View File

@ -1,26 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
},
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"args": [
"runserver"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/manage.py"
}
]
}

26
.vscode/settings.json vendored
View File

@ -4,30 +4,8 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "strict",
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
"editor.defaultFormatter": "ms-python.black-formatter"
},
},
"ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"],
"tailwind-fold.supportedLanguages": [
"html",
"typescriptreact",
"javascriptreact",
"typescript",
"javascript",
"vue-html",
"vue",
"php",
"markdown",
"coffeescript",
"svelte",
"astro",
"erb",
"django-html"
]
}

View File

@ -1,41 +1,8 @@
## Unreleased
## New
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
* Add all-time stats
* Manage purchases
* Automatically convert purchase prices
* Add emulated property to sessions
* Add today's and last 7 days playtime stats to navbar
## Improved
* mark refunded purchases red on game overview
* increase session count on game overview when starting a new session
* game overview:
* sort purchases also by date purchased (on top of date released)
* improve header format, make it more appealing
* ignore manual sessions when calculating session average
* stats: improve purchase name consistency
* session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover
## Fixed
* Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions
## 1.5.2 / 2024-01-14 21:27+01:00
## Improved
* game overview:
* improve how editions and purchases are displayed
* make it possible to end session from overview
* game overview: improve how editions and purchases are displayed
* 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

View File

@ -1,6 +1,6 @@
FROM python:3.12.0-slim-bullseye
ENV VERSION_NUMBER=1.5.2 \
ENV VERSION_NUMBER=1.5.1 \
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \

View File

@ -1,36 +1,20 @@
all: css migrate
all: migrate
initialize: npm css migrate sethookdir loadplatforms
initialize: npm migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
npm:
npm install
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
makemigrations:
poetry run python manage.py makemigrations
migrate: makemigrations
poetry run python manage.py migrate
init:
pyenv install -s $(PYTHON_VERSION)
pyenv local $(PYTHON_VERSION)
pip install poetry
poetry install
npm install
dev:
@npx concurrently \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"poetry run python -Wa manage.py runserver" \
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
dev: migrate
poetry run python manage.py runserver
caddy:
caddy run --watch

View File

@ -1,15 +1,3 @@
# Timetracker
A simple game catalogue and play session tracker.
# Development
The project uses `pyenv` to manage installed Python versions.
If you have `pyenv` installed, you can simply run:
```
make init
```
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
Afterwards, you can start the development server using `make dev`.

View File

@ -1,287 +0,0 @@
from random import choices as random_choices
from string import ascii_lowercase
from typing import Any, Callable
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate
from games.models import Game, Purchase, Session
HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str
def Component(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
template: str = "",
tag_name: str = "",
) -> HTMLTag:
if not tag_name and not template:
raise ValueError("One of template or tag_name is required.")
if isinstance(children, str):
children = [children]
childrenBlob = "\n".join(children)
if len(attributes) == 0:
attributesBlob = ""
else:
attributesList = [f'{name}="{value}"' for name, value in attributes]
# make attribute list into a string
# and insert space between tag and attribute list
attributesBlob = f" {' '.join(attributesList)}"
tag: str = ""
if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
elif template != "":
tag = render_to_string(
template,
{name: value for name, value in attributes}
| {"slot": mark_safe("\n".join(children))},
)
return mark_safe(tag)
def randomid(seed: str = "", length: int = 10) -> str:
return seed + "".join(random_choices(ascii_lowercase, k=length))
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
id = randomid()
return Component(
attributes=attributes
+ [
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
],
children=children,
template="cotton/popover.html",
)
def PopoverTruncated(
input_string: str,
popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
The truncated text ends in `ellipsis`, and optionally
an always-visible `endpart` can be specified.
`popover_content` can be specified if:
1. It needs to be always displayed regardless if text is truncated.
2. It needs to differ from `input_string`.
"""
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
return Popover(
wrapped_content=truncated,
popover_content=popover_content if popover_content else input_string,
)
else:
if popover_content and popover_if_not_truncated:
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
else:
return input_string
def A(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "",
):
"""
Returns the HTML tag "a".
"url" can either be:
- URL (string)
- path name passed to reverse() (string)
- function
"""
additional_attributes = []
if url:
if type(url) is str:
try:
url_result = reverse(url)
except NoReverseMatch:
url_result = url
elif callable(url):
url_result = url()
else:
raise TypeError("'url' is neither str nor function.")
additional_attributes = [("href", url_result)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
size: str = "base",
icon: bool = False,
color: str = "blue",
):
return Component(
template="cotton/button.html",
attributes=attributes + [("size", size), ("icon", icon), ("color", color)],
children=children,
)
def Div(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Form(
action="",
method="get",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="form",
attributes=attributes + [("action", action), ("method", method)],
children=children,
)
def Icon(
name: str,
attributes: list[HTMLAttribute] = [],
):
try:
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
except TemplateDoesNotExist:
result = Icon(name="unspecified", attributes=attributes)
return result
def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
game_count = purchase.games.count()
popover_if_not_truncated = False
if game_count == 1:
link_content += purchase.games.first().name
popover_content = link_content
if game_count > 1:
if purchase.name:
link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{game_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul>
"""
icon = purchase.platform.icon if game_count == 1 else "unspecified"
if link_content == "":
raise ValueError("link_content is empty!!")
a_content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
icon,
[("title", "Multiple")],
),
PopoverTruncated(
input_string=link_content,
popover_content=mark_safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated,
),
],
)
return mark_safe(A(url=link, children=[a_content]))
def NameWithIcon(
name: str = "",
platform: str = "",
game_id: int = 0,
session_id: int = 0,
purchase_id: int = 0,
linkify: bool = True,
emulated: bool = False,
) -> SafeText:
create_link = False
link = ""
platform = None
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
create_link = True
if session_id:
session = Session.objects.get(pk=session_id)
emulated = session.emulated
game_id = session.game.pk
if purchase_id:
purchase = Purchase.objects.get(pk=purchase_id)
game_id = purchase.games.first().pk
if game_id:
game = Game.objects.get(pk=game_id)
name = name or game.name
platform = game.platform
link = reverse("view_game", args=[int(game_id)])
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
)
if platform
else "",
Icon("emulated", [("title", "Emulated")]) if emulated else "",
PopoverTruncated(name),
],
)
return mark_safe(
A(
url=link,
children=[content],
)
if create_link
else content,
)
def PurchasePrice(purchase) -> str:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
)

View File

@ -1,195 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans Condensed";
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
/* a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
} */
/* form label {
@apply dark:text-slate-400;
} */
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
}
.responsive-table tr:nth-child(even) {
@apply bg-slate-800
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
@layer utilities {
.min-w-20char {
min-width: 20ch;
}
.max-w-20char {
max-width: 20ch;
}
.min-w-30char {
min-width: 30ch;
}
.max-w-30char {
max-width: 30ch;
}
.max-w-35char {
max-width: 35ch;
}
.max-w-40char {
max-width: 40ch;
}
}
/* form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} */
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
/* @media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
} */
/* @media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} */
#button-container button {
@apply mx-1;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}
.markdown-content ul {
list-style-type: disc;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ol {
list-style-type: decimal;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ul,
.markdown-content ol {
list-style-position: outside;
padding-left: 1em;
}
.markdown-content ul ul,
.markdown-content ul ol,
.markdown-content ol ul,
.markdown-content ol ol {
list-style-type: circle;
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1em;
}
/* .truncate-container {
@apply inline-block relative;
a {
@apply inline-block truncate max-w-20char transition-all group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4;
}
} */
label {
@apply dark:text-slate-500;
}
[type="text"], [type="password"], [type="datetime-local"], [type="datetime"], [type="date"], [type="number"], select, textarea {
@apply dark:bg-slate-600 dark:text-slate-300;
}
[type="submit"] {
@apply dark:text-white font-bold dark:bg-blue-600 px-4 py-2;
}
form div label {
@apply dark:text-white;
}
form div {
@apply flex flex-col;
}
div [type="submit"] {
@apply mt-3;
}

View File

@ -1,19 +1,9 @@
import re
from datetime import date, datetime, timedelta
from django.utils import timezone
from common.utils import generate_split_ranges
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
from datetime import timedelta
def _safe_timedelta(duration: timedelta | int | None):
if duration is None:
if duration == None:
return timedelta(0)
elif isinstance(duration, int):
return timedelta(seconds=duration)
@ -22,7 +12,7 @@ def _safe_timedelta(duration: timedelta | int | None):
def format_duration(
duration: timedelta | int | float | None, format_string: str = "%H hours"
duration: timedelta | int | None, format_string: str = "%H hours"
) -> str:
"""
Format timedelta into the specified format_string.
@ -80,90 +70,3 @@ def format_duration(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
return timezone.localtime(datetime).strftime(format)
def daterange(start: date, end: date, end_inclusive: bool = False) -> list[date]:
time_between: timedelta = end - start
if (days_between := time_between.days) < 1:
raise ValueError("start and end have to be at least 1 day apart.")
if end_inclusive:
print(f"{end_inclusive=}")
print(f"{days_between=}")
days_between += 1
print(f"{days_between=}")
return [start + timedelta(x) for x in range(days_between)]
def streak(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if len(datelist) == 1:
return {"days": 1, "dates": (datelist[0], datelist[0])}
else:
print(f"Processing {len(datelist)} dates.")
missing = sorted(
set(
datelist[0] + timedelta(x)
for x in range((datelist[-1] - datelist[0]).days)
)
- set(datelist)
)
print(f"{len(missing)} days missing.")
datelist_with_missing = sorted(datelist + missing)
ranges = list(generate_split_ranges(datelist_with_missing, missing))
print(f"{len(ranges)} ranges calculated.")
longest_consecutive_days = timedelta(0)
longest_range: tuple[date, date] = (date(1970, 1, 1), date(1970, 1, 1))
for start, end in ranges:
if (current_streak := end - start) > longest_consecutive_days:
longest_consecutive_days = current_streak
longest_range = (start, end)
return {"days": longest_consecutive_days.days + 1, "dates": longest_range}
def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if (datelist_length := len(datelist)) == 0:
raise ValueError("Number of dates in the list is 0.")
datelist.sort()
current_streak = 1
current_start = datelist[0]
current_end = datelist[0]
current_date = datelist[0]
highest_streak = 1
highest_streak_daterange = (current_start, current_end)
def update_highest_streak():
nonlocal highest_streak, highest_streak_daterange
if current_streak > highest_streak:
highest_streak = current_streak
highest_streak_daterange = (current_start, current_end)
def reset_streak():
nonlocal current_start, current_end, current_streak
current_start = current_end = current_date
current_streak = 1
def increment_streak():
nonlocal current_end, current_streak
current_end = current_date
current_streak += 1
for i, datelist_item in enumerate(datelist, start=1):
current_date = datelist_item
if current_date == current_start or current_date == current_end:
continue
if current_date - timedelta(1) != current_end and i != datelist_length:
update_highest_streak()
reset_streak()
elif current_date - timedelta(1) == current_end and i == datelist_length:
increment_streak()
update_highest_streak()
else:
increment_streak()
return {"days": highest_streak, "dates": highest_streak_daterange}
def available_stats_year_range():
return range(datetime.now().year, 1999, -1)

View File

@ -1,15 +1,3 @@
import operator
from dataclasses import dataclass
from datetime import date
from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
@ -19,149 +7,3 @@ def safe_division(numerator: int | float, denominator: int | float) -> int | flo
return numerator / denominator
except ZeroDivisionError:
return 0
def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object:
"""
Safely get the nested attribute from an object.
Parameters:
obj (object): The object from which to retrieve the attribute.
attr_chain (str): The chain of attributes, separated by dots.
default: The default value to return if any attribute in the chain does not exist.
Returns:
The value of the nested attribute if it exists, otherwise the default value.
"""
attrs = attr_chain.split(".")
for attr in attrs:
try:
obj = getattr(obj, attr)
except AttributeError:
return default
return obj
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
else input_string
)
def truncate(
input_string: str, length: int = 30, ellipsis: str = "", endpart: str = ""
) -> str:
max_content_length = length - len(endpart)
if max_content_length < 0:
raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length:
return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return (
f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
)
T = TypeVar("T", str, int, date)
def generate_split_ranges(
value_list: list[T], split_points: list[T]
) -> Generator[tuple[T, T], None, None]:
for x in range(0, len(split_points) + 1):
if x == 0:
start = 0
elif x >= len(split_points):
start = value_list.index(split_points[x - 1]) + 1
else:
start = value_list.index(split_points[x - 1]) + 1
try:
end = value_list.index(split_points[x])
except IndexError:
end = len(value_list)
yield (value_list[start], value_list[end - 1])
def format_float_or_int(number: int | float):
return int(number) if float(number).is_integer() else f"{number:03.2f}"
OperatorType = Literal["|", "&"]
@dataclass
class FilterEntry:
condition: Q
operator: OperatorType = "&"
def build_dynamic_filter(
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
):
"""
Constructs a Django Q filter from a list of filter conditions.
Args:
filters (list): A list where each item is either:
- A Q object (default AND logic applied)
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
Returns:
Q: A combined Q object that can be passed to Django's filter().
"""
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
"|": operator.or_,
"&": operator.and_,
}
# Convert all plain Q objects into (Q, "&") for default AND behavior
processed_filters = [
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
]
# Reduce with dynamic operators
return reduce(
lambda combined_filters, filter: op_map[filter.operator](
combined_filters, filter.condition
),
processed_filters,
Q(),
)
def redirect_to(default_view: str, *default_args):
"""
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
:param default_view: The name of the default view to redirect to if 'next' is missing.
:param default_args: Any arguments required for the default view.
"""
def decorator(view_func):
@wraps(view_func)
def wrapped_view(request: HttpRequest, *args, **kwargs):
next_url = request.GET.get("next")
if not next_url:
from django.urls import (
reverse, # Import inside function to avoid circular imports
)
next_url = reverse(default_view, args=default_args)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
return redirect(next_url)
return wrapped_view
return decorator
def add_next_param_to_url(url: str, nexturl: str) -> str:
return f"{url}?{urlencode({'next': nexturl})}"

View File

@ -1,33 +0,0 @@
from datetime import datetime
import requests
url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json"
date_format = "%Y%m%d"
years = range(2000, datetime.now().year + 1)
dates = [
datetime.strftime(datetime(day=1, month=1, year=year), format=date_format)
for year in years
]
for date in dates:
final_url = url.format(date)
year = date[:4]
response = requests.get(final_url)
response.raise_for_status()
data = response.json()
if kurzy := data.get("kurzy"):
with open("output.yaml", mode="a") as o:
rates = [
f"""
- model: games.exchangerate
fields:
currency_from: {currency_name}
currency_to: CZK
year: {year}
rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)}
"""
for currency_name in ["EUR", "USD", "CNY"]
if kurzy.get(currency_name)
]
o.writelines(rates)
# time.sleep(0.5)

View File

@ -1,65 +0,0 @@
import sys
import yaml
def load_yaml(filename):
with open(filename, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or []
def save_yaml(filename, data):
with open(filename, "w", encoding="utf-8") as file:
yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False)
def extract_existing_combinations(data):
return {
(
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
for entry in data
if entry["model"] == "games.exchangerate"
}
def filter_new_entries(existing_combinations, additional_files):
new_entries = []
for filename in additional_files:
data = load_yaml(filename)
for entry in data:
if entry["model"] == "games.exchangerate":
key = (
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
if key not in existing_combinations:
new_entries.append(entry)
return new_entries
def main():
if len(sys.argv) < 3:
print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]")
sys.exit(1)
example_file = sys.argv[1]
additional_files = sys.argv[2:]
output_file = "filtered_output.yaml"
existing_data = load_yaml(example_file)
existing_combinations = extract_existing_combinations(existing_data)
new_entries = filter_new_entries(existing_combinations, additional_files)
save_yaml(output_file, new_entries)
print(f"Filtered data saved to {output_file}")
if __name__ == "__main__":
main()

View File

@ -1,24 +0,0 @@
FROM python:3.13-slim
# Set up environment
ENV PYTHONUNBUFFERED=1
WORKDIR /workspace
# Install Poetry
RUN apt-get update && apt-get install -y \
curl \
make \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
# Copy pyproject.toml and poetry.lock for dependency installation
COPY pyproject.toml poetry.lock* ./
RUN poetry install --no-root
# Copy the rest of the application code
COPY . .
# Set up Django development server
EXPOSE 8000

View File

@ -10,14 +10,10 @@ poetry run python manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid"
kill -SIGTERM "$django_q_pid"
}
trap _term SIGTERM
echo "Starting Django-Q cluster"
poetry run python manage.py qcluster & django_q_pid=$!
echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid" "$django_q_pid"
wait "$gunicorn_pid"

17
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
env: {
browser: true,
es2021: true
},
extends: ["eslint/recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
overrides: [],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
plugins: ["react"],
rules: {},
parserOptions: {
ecmaFeatures: { jsx: true }
}
};

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
frontend/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "none",
"bracketSameLine": false,
"singleAttributePerLine": true
}

17
frontend/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Self-hosted time-tracker."/>
<meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: replace with own icon -->
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
<title>Timetracker</title>
</head>
<body class="dark">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
frontend/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.2.4"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^3.0.0",
"eslint": "^8.32.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.1",
"eslint-plugin-react-hooks": "^4.6.0",
"vite": "^4.0.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

42
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,42 @@
import { useState } from 'react'
import './App.css'
function App() {
return (
<>
<div className="dark:bg-gray-800 min-h-screen">
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div className="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'index' %}" className="flex items-center">
<span className="text-4xl"></span>
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
<div className="w-full md:block md:w-auto">
<ul
className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
{/* {% if game_available and platform_available %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{/* {% endif %} */}
{/* {% if purchase_available %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
{/* {% endif %} */}
{/* {% if session_count > 0 %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{/* {% endif %} */}
</ul>
</div>
</div>
</nav>
{/* {% block content %}No content here.{% endblock content %} */}
</div>
{/* {% load version %} */}
{/* <span className="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> */}
</>
)
}
export default App;

View File

@ -0,0 +1,71 @@
import { Link } from 'react-router-dom';
function Nav() {
return (
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div className="container flex flex-wrap items-center justify-between mx-auto">
<Link
to="/"
className="flex items-center"
>
<span className="text-4xl"></span>
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">
Timetracker
</span>
</Link>
<div className="w-full md:block md:w-auto">
<ul className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_game' %}"
>
New Game
</a>
</li>
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_platform' %}"
>
New Platform
</a>
</li>
{/* {% if game_available and platform_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_purchase' %}"
>
New Purchase
</a>
</li>
{/* {% endif %} */}
{/* {% if purchase_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_session' %}"
>
New Session
</a>
</li>
{/* {% endif %} */}
{/* {% if session_count > 0 %} */}
<li>
<Link
className="block py-2 pl-3 pr-4 hover:underline"
to="/sessions"
>
All Sessions
</Link>
</li>
{/* {% endif %} */}
</ul>
</div>
</div>
</nav>
);
}
export default Nav;

View File

@ -0,0 +1,162 @@
export default function SessionList() {
const data = [
{
"url": "http://localhost:8000/api/sessions/25/",
"timestamp_start": "2020-01-01T00:00:00+01:00",
"timestamp_end": null,
"duration_manual": "12:00:00",
"duration_calculated": "00:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/26/",
"timestamp_start": "2022-12-31T15:25:00+01:00",
"timestamp_end": "2022-12-31T17:25:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/2/"
},
{
"url": "http://localhost:8000/api/sessions/27/",
"timestamp_start": "2023-01-01T23:00:00+01:00",
"timestamp_end": "2023-01-02T00:28:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:28:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/28/",
"timestamp_start": "2023-01-02T22:08:00+01:00",
"timestamp_end": "2023-01-03T01:08:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "03:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/29/",
"timestamp_start": "2023-01-03T22:36:00+01:00",
"timestamp_end": "2023-01-04T00:12:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:36:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/30/",
"timestamp_start": "2023-01-04T20:35:00+01:00",
"timestamp_end": "2023-01-04T22:36:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:01:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/31/",
"timestamp_start": "2023-01-06T18:48:00+01:00",
"timestamp_end": "2023-01-06T23:39:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "04:51:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/32/",
"timestamp_start": "2023-01-07T23:49:00+01:00",
"timestamp_end": "2023-01-08T01:43:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:54:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/33/",
"timestamp_start": "2023-01-08T16:21:00+01:00",
"timestamp_end": "2023-01-08T18:27:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:06:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/34/",
"timestamp_start": "2023-01-08T19:04:00+01:00",
"timestamp_end": "2023-01-08T22:03:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:59:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/35/",
"timestamp_start": "2023-01-09T19:35:48+01:00",
"timestamp_end": "2023-01-09T22:13:20.519058+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:37:32.519058",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/36/",
"timestamp_start": "2023-01-10T15:50:12+01:00",
"timestamp_end": "2023-01-10T17:03:45.424429+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:13:33.424429",
"note": "",
"purchase": "http://localhost:8000/api/purchases/4/"
}
]
const header = ["url", "timestamp_start", "timestamp_end", "duration_manual", "duration_calculated", "note", "purchase"]
// const header = ["Name", "Platform", "Start", "End", "Duration", "Manage"]
return (
<>
<div id="session-table" className="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
{header.map(column => {
<div className="dark:border-white dark:text-slate-300 text-lg">{column}</div>
})}
{data.map(session => {
<>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.url }
</a>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_start }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_end }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_manual }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_calculated }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.note }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.purchase }
</a>
</div>
</div>
</>
})}
</div>
</>
)
}

View File

@ -0,0 +1,16 @@
import { useRouteError } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError()
console.error(error)
return (
<div className="container text-center">
<h1 className="text-3xl">Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
)
}

22
frontend/src/index.css Normal file
View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
form label {
@apply dark:text-slate-400;
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
#session-table {
display: grid;
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {
@apply mx-1;
}

34
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,34 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
// import { loader as sessionLoader } from './routes/sessions'
import ErrorPage from "./error-page"
import SessionList from './components/SessionList'
// import Session from './routes/sessions'
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage />,
// loader: sessionLoader,
children: [
{
path: "sessions/",
element: <SessionList />
}
]
},
// {
// path: "sessions",
// element: <SessionList />
// }
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)

View File

@ -0,0 +1,17 @@
export async function api(url) {
const response = await fetch(url);
if (response.ok) {
const jsonValue = await response.json();
return Promise.resolve(jsonValue);
} else {
return Promise.reject('Response was not OK.');
}
}
export async function getSession(sessionId) {
return await api(`/api/sessions/${sessionId}/`);
}
export async function getSessionList() {
return await api(`/api/sessions/`);
}

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
fontFamily: {
sans: ["Inter", "sans-serif"],
},
extend: {},
},
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
};

12
frontend/vite.config.js Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://127.0.0.1:8001",
},
},
});

View File

@ -1,18 +1,11 @@
from django.contrib import admin
from games.models import (
Device,
ExchangeRate,
Game,
Platform,
Purchase,
Session,
)
from games.models import Device, Edition, Game, Platform, Purchase, Session
# Register your models here.
admin.site.register(Game)
admin.site.register(Purchase)
admin.site.register(Platform)
admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device)
admin.site.register(ExchangeRate)

View File

@ -1,80 +0,0 @@
from datetime import date, datetime
from typing import List
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
from games.models import PlayEvent
api = NinjaAPI()
playevent_router = Router()
NOW_FACTORY = django_timezone_now
class PlayEventIn(Schema):
game_id: int
started: date | None = None
ended: date | None = None
note: str = ""
days_to_finish: int | None = None
class AutoPlayEventIn(ModelSchema):
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note"]
class UpdatePlayEventIn(Schema):
started: date | None = None
ended: date | None = None
note: str = ""
class PlayEventOut(Schema):
id: int
game: str = Field(..., alias="game.name")
started: date | None = None
ended: date | None = None
days_to_finish: int | None = None
note: str = ""
updated_at: datetime
created_at: datetime
@playevent_router.get("/", response=List[PlayEventOut])
def list_playevents(request):
return PlayEvent.objects.all()
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
return playevent
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
def get_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
return playevent
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(playevent, attr, value)
playevent.save()
return playevent
@playevent_router.delete("/{playevent_id}", response={204: None})
def delete_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return 204, None
api.add_router("/playevent", playevent_router)

View File

@ -1,46 +1,6 @@
# from datetime import timedelta
from django.apps import AppConfig
from django.core.management import call_command
from django.db.models.signals import post_migrate
# from django.utils.timezone import now
class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "games"
def ready(self):
import games.signals # noqa: F401
post_migrate.connect(schedule_tasks, sender=self)
def schedule_tasks(sender, **kwargs):
# from django_q.models import Schedule
# from django_q.tasks import schedule
# if not Schedule.objects.filter(name="Update converted prices").exists():
# schedule(
# "games.tasks.convert_prices",
# name="Update converted prices",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
# if not Schedule.objects.filter(name="Update price per game").exists():
# schedule(
# "games.tasks.calculate_price_per_game",
# name="Update price per game",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
from games.models import ExchangeRate
if not ExchangeRate.objects.exists():
print("ExchangeRate table is empty. Loading fixture...")
call_command("loaddata", "exchangerates.yaml")

View File

@ -1,504 +0,0 @@
- model: games.exchangerate
pk: 1
fields:
currency_from: USD
currency_to: CZK
year: 2024
rate: 23.4
- model: games.exchangerate
pk: 2
fields:
currency_from: CNY
currency_to: CZK
year: 2024
rate: 3.267
- model: games.exchangerate
pk: 3
fields:
currency_from: USD
currency_to: CZK
year: 2019
rate: 22.466
- model: games.exchangerate
pk: 4
fields:
currency_from: USD
currency_to: CZK
year: 2023
rate: 22.63
- model: games.exchangerate
pk: 5
fields:
currency_from: USD
currency_to: CZK
year: 2017
rate: 25.819
- model: games.exchangerate
pk: 6
fields:
currency_from: USD
currency_to: CZK
year: 2013
rate: 19.023
- model: games.exchangerate
pk: 7
fields:
currency_from: CNY
currency_to: CZK
year: 2019
rate: 3.295
- model: games.exchangerate
pk: 8
fields:
currency_from: CNY
currency_to: CZK
year: 2016
rate: 3.795
- model: games.exchangerate
pk: 9
fields:
currency_from: CNY
currency_to: CZK
year: 2015
rate: 3.707
- model: games.exchangerate
pk: 10
fields:
currency_from: CNY
currency_to: CZK
year: 2020
rate: 3.26
- model: games.exchangerate
pk: 11
fields:
currency_from: EUR
currency_to: CZK
year: 2012
rate: 25.51
- model: games.exchangerate
pk: 12
fields:
currency_from: EUR
currency_to: CZK
year: 2010
rate: 26.465
- model: games.exchangerate
pk: 13
fields:
currency_from: EUR
currency_to: CZK
year: 2014
rate: 27.52
- model: games.exchangerate
pk: 14
fields:
currency_from: EUR
currency_to: CZK
year: 2024
rate: 25.21
- model: games.exchangerate
pk: 15
fields:
currency_from: EUR
currency_to: CZK
year: 2022
rate: 24.325
- model: games.exchangerate
pk: 16
fields:
currency_from: CNY
currency_to: CZK
year: 2018
rate: 3.268
- model: games.exchangerate
pk: 17
fields:
currency_from: CNY
currency_to: CZK
year: 2023
rate: 3.281
- model: games.exchangerate
pk: 18
fields:
currency_from: EUR
currency_to: CZK
year: 2009
rate: 26.445
- model: games.exchangerate
pk: 19
fields:
currency_from: CNY
currency_to: CZK
year: 2025
rate: 3.35
- model: games.exchangerate
pk: 20
fields:
currency_from: EUR
currency_to: CZK
year: 2016
rate: 27.033
- model: games.exchangerate
pk: 21
fields:
currency_from: EUR
currency_to: CZK
year: 2025
rate: 25.2021966
- model: games.exchangerate
pk: 22
fields:
currency_from: EUR
currency_to: CZK
year: 2017
rate: 26.33
- model: games.exchangerate
pk: 23
fields:
currency_from: EUR
currency_to: CZK
year: 2000
rate: 36.13
- model: games.exchangerate
pk: 24
fields:
currency_from: USD
currency_to: CZK
year: 2000
rate: 35.979
- model: games.exchangerate
pk: 25
fields:
currency_from: EUR
currency_to: CZK
year: 2001
rate: 35.09
- model: games.exchangerate
pk: 26
fields:
currency_from: USD
currency_to: CZK
year: 2001
rate: 37.813
- model: games.exchangerate
pk: 27
fields:
currency_from: EUR
currency_to: CZK
year: 2002
rate: 31.98
- model: games.exchangerate
pk: 28
fields:
currency_from: USD
currency_to: CZK
year: 2002
rate: 36.259
- model: games.exchangerate
pk: 29
fields:
currency_from: EUR
currency_to: CZK
year: 2003
rate: 31.6
- model: games.exchangerate
pk: 30
fields:
currency_from: USD
currency_to: CZK
year: 2003
rate: 30.141
- model: games.exchangerate
pk: 31
fields:
currency_from: EUR
currency_to: CZK
year: 2004
rate: 32.405
- model: games.exchangerate
pk: 32
fields:
currency_from: USD
currency_to: CZK
year: 2004
rate: 25.654
- model: games.exchangerate
pk: 33
fields:
currency_from: EUR
currency_to: CZK
year: 2005
rate: 30.465
- model: games.exchangerate
pk: 34
fields:
currency_from: USD
currency_to: CZK
year: 2005
rate: 22.365
- model: games.exchangerate
pk: 35
fields:
currency_from: EUR
currency_to: CZK
year: 2006
rate: 29.005
- model: games.exchangerate
pk: 36
fields:
currency_from: USD
currency_to: CZK
year: 2006
rate: 24.588
- model: games.exchangerate
pk: 37
fields:
currency_from: CNY
currency_to: CZK
year: 2006
rate: 3.047
- model: games.exchangerate
pk: 38
fields:
currency_from: EUR
currency_to: CZK
year: 2007
rate: 27.495
- model: games.exchangerate
pk: 39
fields:
currency_from: USD
currency_to: CZK
year: 2007
rate: 20.876
- model: games.exchangerate
pk: 40
fields:
currency_from: CNY
currency_to: CZK
year: 2007
rate: 2.674
- model: games.exchangerate
pk: 41
fields:
currency_from: EUR
currency_to: CZK
year: 2008
rate: 26.62
- model: games.exchangerate
pk: 42
fields:
currency_from: USD
currency_to: CZK
year: 2008
rate: 18.078
- model: games.exchangerate
pk: 43
fields:
currency_from: CNY
currency_to: CZK
year: 2008
rate: 2.475
- model: games.exchangerate
pk: 44
fields:
currency_from: USD
currency_to: CZK
year: 2009
rate: 19.346
- model: games.exchangerate
pk: 45
fields:
currency_from: CNY
currency_to: CZK
year: 2009
rate: 2.836
- model: games.exchangerate
pk: 46
fields:
currency_from: USD
currency_to: CZK
year: 2010
rate: 18.368
- model: games.exchangerate
pk: 47
fields:
currency_from: CNY
currency_to: CZK
year: 2010
rate: 2.691
- model: games.exchangerate
pk: 48
fields:
currency_from: EUR
currency_to: CZK
year: 2011
rate: 25.06
- model: games.exchangerate
pk: 49
fields:
currency_from: USD
currency_to: CZK
year: 2011
rate: 18.751
- model: games.exchangerate
pk: 50
fields:
currency_from: CNY
currency_to: CZK
year: 2011
rate: 2.845
- model: games.exchangerate
pk: 51
fields:
currency_from: USD
currency_to: CZK
year: 2012
rate: 19.94
- model: games.exchangerate
pk: 52
fields:
currency_from: CNY
currency_to: CZK
year: 2012
rate: 3.168
- model: games.exchangerate
pk: 53
fields:
currency_from: EUR
currency_to: CZK
year: 2013
rate: 25.14
- model: games.exchangerate
pk: 54
fields:
currency_from: CNY
currency_to: CZK
year: 2013
rate: 3.059
- model: games.exchangerate
pk: 55
fields:
currency_from: USD
currency_to: CZK
year: 2014
rate: 19.894
- model: games.exchangerate
pk: 56
fields:
currency_from: CNY
currency_to: CZK
year: 2014
rate: 3.286
- model: games.exchangerate
pk: 57
fields:
currency_from: EUR
currency_to: CZK
year: 2015
rate: 27.725
- model: games.exchangerate
pk: 58
fields:
currency_from: USD
currency_to: CZK
year: 2015
rate: 22.834
- model: games.exchangerate
pk: 59
fields:
currency_from: USD
currency_to: CZK
year: 2016
rate: 24.824
- model: games.exchangerate
pk: 60
fields:
currency_from: CNY
currency_to: CZK
year: 2017
rate: 3.693
- model: games.exchangerate
pk: 61
fields:
currency_from: EUR
currency_to: CZK
year: 2018
rate: 25.54
- model: games.exchangerate
pk: 62
fields:
currency_from: USD
currency_to: CZK
year: 2018
rate: 21.291
- model: games.exchangerate
pk: 63
fields:
currency_from: EUR
currency_to: CZK
year: 2019
rate: 25.725
- model: games.exchangerate
pk: 64
fields:
currency_from: EUR
currency_to: CZK
year: 2020
rate: 25.41
- model: games.exchangerate
pk: 65
fields:
currency_from: USD
currency_to: CZK
year: 2020
rate: 22.621
- model: games.exchangerate
pk: 66
fields:
currency_from: EUR
currency_to: CZK
year: 2021
rate: 26.245
- model: games.exchangerate
pk: 67
fields:
currency_from: USD
currency_to: CZK
year: 2021
rate: 21.387
- model: games.exchangerate
pk: 68
fields:
currency_from: CNY
currency_to: CZK
year: 2021
rate: 3.273
- model: games.exchangerate
pk: 69
fields:
currency_from: USD
currency_to: CZK
year: 2022
rate: 21.951
- model: games.exchangerate
pk: 70
fields:
currency_from: CNY
currency_to: CZK
year: 2022
rate: 3.458
- model: games.exchangerate
pk: 71
fields:
currency_from: EUR
currency_to: CZK
year: 2023
rate: 24.115
- model: games.exchangerate
pk: 72
fields:
currency_from: USD
currency_to: CZK
year: 2025
rate: 24.237

View File

@ -1,17 +1,7 @@
from django import forms
from django.db import transaction
from django.urls import reverse
from common.utils import safe_getattr
from games.models import (
Device,
Game,
GameStatusChange,
Platform,
PlayEvent,
Purchase,
Session,
)
from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
@ -20,37 +10,17 @@ custom_datetime_widget = forms.DateTimeInput(
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SingleGameChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
# purchase = forms.ModelChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
duration_manual = forms.DurationField(
required=False,
widget=forms.TextInput(
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
mark_as_played = forms.BooleanField(
required=False,
initial={"mark_as_played": True},
label="Set game status to Played if Unplayed",
)
class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
@ -58,34 +28,25 @@ class SessionForm(forms.ModelForm):
}
model = Session
fields = [
"game",
"purchase",
"timestamp_start",
"timestamp_end",
"duration_manual",
"emulated",
"device",
"note",
"mark_as_played",
]
def save(self, commit=True):
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_played"):
game_instance = session.game
if game_instance.status == "u":
game_instance.status = "p"
if commit:
game_instance.save()
if commit:
session.save()
return session
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.SelectMultiple):
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"):
option["attrs"]["data-platform"] = platform_id
if value:
option["attrs"]["data-platform"] = value.instance.platform.id
return option
@ -94,51 +55,42 @@ class PurchaseForm(forms.ModelForm):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected game.
related_purchase_by_game_url = reverse("related_purchase_by_game")
self.fields["games"].widget.attrs.update(
# 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_game_url,
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME),
required=False,
)
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
label="Currency",
required=False,
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
}
model = Purchase
fields = [
"games",
"edition",
"platform",
"date_purchased",
"date_refunded",
"infinite",
"date_finished",
"price",
"price_currency",
"ownership_type",
@ -184,34 +136,31 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name
class GameForm(forms.ModelForm):
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = [
"name",
"sort_name",
"platform",
"original_year_released",
"year_released",
"status",
"mastered",
"wikidata",
]
fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = [
"name",
"icon",
"group",
]
fields = ["name", "group"]
widgets = {"name": autofocus_input_widget}
@ -220,48 +169,3 @@ class DeviceForm(forms.ModelForm):
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
mark_as_finished = forms.BooleanField(
required=False,
initial={"mark_as_finished": True},
label="Set game status to Finished",
)
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note", "mark_as_finished"]
widgets = {
"started": custom_date_widget,
"ended": custom_date_widget,
}
def save(self, commit=True):
with transaction.atomic():
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_finished"):
game_instance = session.game
game_instance.status = "f"
game_instance.save()
session.save()
return session
class GameStatusChangeForm(forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
"game",
"old_status",
"new_status",
"timestamp",
]
widgets = {
"timestamp": custom_datetime_widget,
}

View File

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

View File

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

@ -1,5 +0,0 @@
from .device import Query as DeviceQuery
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

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

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

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

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

@ -1,11 +0,0 @@
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()

View File

@ -1,44 +0,0 @@
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,24 +0,0 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from django_q.models import Schedule
from django_q.tasks import schedule
class Command(BaseCommand):
help = "Manually schedule the next update_converted_prices task"
def handle(self, *args, **kwargs):
if not Schedule.objects.filter(name="Update converted prices").exists():
schedule(
"games.tasks.convert_prices",
name="Update converted prices",
schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30),
)
self.stdout.write(
self.style.SUCCESS("Scheduled the update_converted_prices task.")
)
else:
self.stdout.write(self.style.WARNING("Task is already scheduled."))

View File

@ -1,6 +1,5 @@
# Generated by Django 5.1.5 on 2025-01-29 21:26
# Generated by Django 4.1.4 on 2023-01-02 18:27
import datetime
import django.db.models.deletion
from django.db import migrations, models
@ -9,96 +8,94 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Device',
name="Game",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("wikidata", models.CharField(max_length=50)),
],
),
migrations.CreateModel(
name='Platform',
name="Platform",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
('icon', models.SlugField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("group", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='ExchangeRate',
name="Purchase",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
},
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date_purchased", models.DateField()),
("date_refunded", models.DateField(blank=True, null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
migrations.CreateModel(
name='Game',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
('year_released', models.IntegerField(blank=True, default=None, null=True)),
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
],
options={
'unique_together': {('name', 'platform', 'year_released')},
},
),
migrations.CreateModel(
name='Purchase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_purchased', models.DateField()),
('date_refunded', models.DateField(blank=True, null=True)),
('date_finished', models.DateField(blank=True, null=True)),
('date_dropped', models.DateField(blank=True, null=True)),
('infinite', models.BooleanField(default=False)),
('price', models.FloatField(default=0)),
('price_currency', models.CharField(default='USD', max_length=3)),
('converted_price', models.FloatField(null=True)),
('converted_currency', models.CharField(max_length=3, null=True)),
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
],
),
migrations.CreateModel(
name='Session',
name="Session",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp_start', models.DateTimeField()),
('timestamp_end', models.DateTimeField(blank=True, null=True)),
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
('duration_calculated', models.DurationField(blank=True, null=True)),
('note', models.TextField(blank=True, null=True)),
('emulated', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timestamp_start", models.DateTimeField()),
("timestamp_end", models.DateTimeField()),
("duration_manual", models.DurationField(blank=True, null=True)),
("duration_calculated", models.DurationField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
(
"purchase",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.purchase",
),
),
],
options={
'get_latest_by': 'timestamp_start',
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.1.4 on 2023-01-02 18:55
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.FloatField(null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.4 on 2023-01-02 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0002_alter_session_duration_manual"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(blank=True, null=True),
),
migrations.AlterField(
model_name="session",
name="timestamp_end",
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0002_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.1.5 on 2023-01-09 14:49
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0003_alter_session_duration_manual_and_more"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:57
from django.db import migrations, models
from django.db.models import Count
def initialize_num_purchases(apps, schema_editor):
Purchase = apps.get_model("games", "Purchase")
purchases = Purchase.objects.annotate(num_games=Count("games"))
for purchase in purchases:
purchase.num_purchases = purchase.num_games
purchase.save(update_fields=["num_purchases"])
class Migration(migrations.Migration):
dependencies = [
("games", "0003_purchase_updated_at"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="num_purchases",
field=models.IntegerField(default=0),
),
migrations.RunPython(initialize_num_purchases),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 4.1.5 on 2023-01-09 17:43
from datetime import timedelta
from django.db import migrations
def set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == None:
session.duration_calculated = timedelta(0)
session.save()
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == timedelta(0):
session.duration_calculated = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0004_alter_session_duration_manual"),
]
operations = [
migrations.RunPython(
set_duration_calculated_none_to_zero,
revert_set_duration_calculated_none_to_zero,
)
]

View File

@ -1,38 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-01 19:18
from django.db import migrations, models
def set_finished_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
class Migration(migrations.Migration):
dependencies = [
("games", "0004_purchase_num_purchases"),
]
operations = [
migrations.AddField(
model_name="game",
name="mastered",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="status",
field=models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
default="u",
max_length=1,
),
),
migrations.RunPython(set_finished_status),
]

View File

@ -1,59 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-01 12:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0005_game_mastered_game_status'),
]
operations = [
migrations.AlterField(
model_name='game',
name='sort_name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='game',
name='wikidata',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AlterField(
model_name='platform',
name='group',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='converted_currency',
field=models.CharField(blank=True, default='', max_length=3),
),
migrations.AlterField(
model_name='purchase',
name='games',
field=models.ManyToManyField(related_name='purchases', to='games.game'),
),
migrations.AlterField(
model_name='purchase',
name='name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='related_purchase',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
),
migrations.AlterField(
model_name='session',
name='game',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
),
migrations.AlterField(
model_name='session',
name='note',
field=models.TextField(blank=True, default=''),
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 4.1.5 on 2023-01-09 18:04
from datetime import timedelta
from django.db import migrations
def set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == None:
session.duration_manual = timedelta(0)
session.save()
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == timedelta(0):
session.duration_manual = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0005_auto_20230109_1843"),
]
operations = [
migrations.RunPython(
set_duration_manual_none_to_zero,
revert_set_duration_manual_none_to_zero,
)
]

View File

@ -0,0 +1,35 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0006_auto_20230109_1904"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
migrations.AlterField(
model_name="session",
name="purchase",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-17 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
]
operations = [
migrations.AddField(
model_name='game',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]

View File

@ -1,190 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-19 13:11
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
from django.db.models import F, Min
def copy_year_released(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.update(original_year_released=F("year_released"))
def set_abandoned_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game = apps.get_model("games", "Game")
PlayEvent = apps.get_model("games", "PlayEvent")
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
finished = Game.objects.filter(purchases__date_finished__isnull=False)
for game in finished:
for purchase in game.purchases.all():
first_session = game.sessions.filter(
timestamp_start__gte=purchase.date_purchased
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
first_session_date = first_session.date() if first_session else None
if purchase.date_finished:
play_event = PlayEvent(
game=game,
started=first_session_date
if first_session_date
else purchase.date_purchased,
ended=purchase.date_finished,
)
play_event.save()
def create_game_status_changes(apps, schema_editor):
Game = apps.get_model("games", "Game")
GameStatusChange = apps.get_model("games", "GameStatusChange")
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
for game in Game.objects.filter(sessions__isnull=False).distinct():
if game.sessions.exists():
earliest_session = game.sessions.earliest()
GameStatusChange.objects.create(
game=game,
old_status="u",
new_status="p",
timestamp=earliest_session.timestamp_start,
)
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_dropped,
)
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_refunded,
)
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
# consider only the first playevent
for game in Game.objects.filter(playevents__isnull=False):
first_playevent = game.playevents.first()
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="f",
timestamp=first_playevent.ended,
)
class Migration(migrations.Migration):
dependencies = [
("games", "0007_game_updated_at"),
]
operations = [
migrations.AddField(
model_name="game",
name="original_year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(copy_year_released),
migrations.CreateModel(
name="GameStatusChange",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"old_status",
models.CharField(
blank=True,
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
null=True,
),
),
(
"new_status",
models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
),
),
("timestamp", models.DateTimeField(null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="status_changes",
to="games.game",
),
),
],
options={
"ordering": ["-timestamp"],
},
),
migrations.CreateModel(
name="PlayEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("started", models.DateField(blank=True, null=True)),
("ended", models.DateField(blank=True, null=True)),
(
"days_to_finish",
models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.RawSQL(
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
[],
),
output_field=models.IntegerField(),
),
),
("note", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="playevents",
to="games.game",
),
),
],
),
migrations.RunPython(set_abandoned_status),
migrations.RunPython(create_game_status_changes),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]

View File

@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-20 11:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='date_dropped',
),
migrations.RemoveField(
model_name='purchase',
name='date_finished',
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='price_per_game',
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0010_remove_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]

View File

@ -1,32 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:30
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_purchase_price_per_game"),
]
operations = [
migrations.RemoveField(
model_name="session",
name="duration_calculated",
),
migrations.AddField(
model_name="session",
name="duration_calculated",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.functions.comparison.Coalesce(
django.db.models.expressions.CombinedExpression(
models.F("timestamp_end"), "-", models.F("timestamp_start")
),
0,
),
output_field=models.DurationField(),
),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]

View File

@ -1,35 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:33
import datetime
from django.db import migrations, models
from django.db.models import F, Sum
def calculate_game_playtime(apps, schema_editor):
Game = apps.get_model("games", "Game")
games = Game.objects.all()
for game in games:
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_total"))
)["total_playtime"]
if total_playtime:
game.playtime = total_playtime
game.save(update_fields=["playtime"])
class Migration(migrations.Migration):
dependencies = [
("games", "0012_alter_session_duration_calculated"),
]
operations = [
migrations.AddField(
model_name="game",
name="playtime",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), editable=False
),
),
migrations.RunPython(calculate_game_playtime),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]

View File

@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:46
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0013_game_playtime'),
]
operations = [
migrations.AddField(
model_name='session',
name='duration_total',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]

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

Some files were not shown because too many files have changed in this diff Show More