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
70 changed files with 2036 additions and 1072 deletions

View File

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

View File

@ -5,7 +5,7 @@ name: default
steps: steps:
- name: test - name: test
image: python:3.10 image: python:3.12
commands: commands:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
@ -30,9 +30,7 @@ steps:
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: auto_tag: true
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when: when:
branch: branch:
exclude: exclude:

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

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,3 +1,15 @@
## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
* add purchase: only allow choosing purchases of selected edition
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00 ## 1.5.0 / 2023-11-14 19:27+01:00
## New ## New

View File

@ -1,27 +1,45 @@
FROM node as css FROM python:3.12.0-slim-bullseye
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
FROM python:3.10.9-slim-bullseye ENV VERSION_NUMBER=1.5.1 \
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_ROOT_USER_ACTION=ignore \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
ENV VERSION_NUMBER 1.5.0 RUN apt-get update && apt-get upgrade -y \
ENV PROD 1 && apt-get install --no-install-recommends -y \
ENV PYTHONUNBUFFERED=1 bash \
curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \
&& poetry --version \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker RUN useradd -m --uid 1000 timetracker \
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
COPY entrypoint.sh / COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]

View File

@ -1,18 +1,12 @@
all: css migrate all: migrate
initialize: npm css migrate sethookdir loadplatforms initialize: npm migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find games/templates -type f)
npm: npm:
npm install npm install
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations poetry run python manage.py makemigrations

View File

@ -1,109 +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;
}
form label {
@apply dark:text-slate-400;
}
.responsive-table {
@apply dark:text-white mx-auto;
}
.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 {
.max-w-20char {
max-width: 20ch;
}
.max-w-35char {
max-width: 40ch;
}
.max-w-40char {
max-width: 40ch;
}
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
@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;
}
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}

View File

@ -1,12 +1,5 @@
import re import re
from datetime import datetime, timedelta from datetime import timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):

View File

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

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: {},
},
}

0
frontend/src/App.css Normal file
View File

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

View File

@ -1,6 +1,7 @@
from django import forms from django import forms
from django.urls import reverse
from games.models import Game, Platform, Purchase, Session, Edition, Device from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
@ -50,13 +51,31 @@ class IncludePlatformSelect(forms.Select):
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField( edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField( related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name") queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False,
) )
class Meta: class Meta:
@ -80,6 +99,27 @@ class PurchaseForm(forms.ModelForm):
"name", "name",
] ]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select): class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30 # Generated by Django 4.1.5 on 2023-01-19 18:30
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29 # Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06 # Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59 # Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10 # Generated by Django 4.1.5 on 2023-11-06 11:10
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14 # Generated by Django 4.1.5 on 2023-11-06 18:14
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
def rename_duplicates(apps, schema_editor): def rename_duplicates(apps, schema_editor):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53 # Generated by Django 4.1.5 on 2023-11-06 16:53
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41 # Generated by Django 4.1.5 on 2023-11-14 08:41
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,6 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05 # Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models from django.db import migrations, models
from games.models import Purchase from games.models import Purchase

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 4.1.5 on 2023-11-15 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0030_alter_purchase_name"),
]
operations = [
migrations.AddField(
model_name="device",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="edition",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="game",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="platform",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="session",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-11-15 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="session",
options={"get_latest_by": "timestamp_start"},
),
migrations.AddField(
model_name="session",
name="modified_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="device",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="edition",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="platform",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="session",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -1,11 +1,11 @@
from datetime import datetime, timedelta from datetime import timedelta
from typing import Any
from zoneinfo import ZoneInfo
from common.time import format_duration from django.core.exceptions import ValidationError
from django.conf import settings
from django.db import models from django.db import models
from django.db.models import F, Manager, Sum from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.time import format_duration
class Game(models.Model): class Game(models.Model):
@ -13,6 +13,7 @@ class Game(models.Model):
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -43,6 +44,7 @@ class Edition(models.Model):
) )
year_released = models.IntegerField(null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.sort_name return self.sort_name
@ -120,18 +122,27 @@ class Purchase(models.Model):
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField( name = models.CharField(max_length=255, default="", null=True, blank=True)
max_length=255, default="Unknown Name", null=True, blank=True
)
related_purchase = models.ForeignKey( related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True "Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
) )
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
platform_info = self.platform additional_info = [
if self.platform != self.edition.platform: self.get_type_display() if self.type != Purchase.GAME else "",
platform_info = f"{self.edition.platform} version on {self.platform}" f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})" if self.platform != self.edition.platform
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
@ -139,12 +150,17 @@ class Purchase(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type == Purchase.GAME: if self.type == Purchase.GAME:
self.name = "" self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -162,6 +178,9 @@ class SessionQuerySet(models.QuerySet):
class Session(models.Model): class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
@ -175,6 +194,8 @@ class Session(models.Model):
default=None, default=None,
) )
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
@ -183,10 +204,10 @@ class Session(models.Model):
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) self.timestamp_end = timezone.now()
def start_now(): def start_now():
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE)) self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
@ -239,6 +260,7 @@ class Device(models.Model):
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN) type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_type_display()})" return f"{self.name} ({self.get_type_display()})"

0
games/serializers.py Normal file
View File

View File

@ -808,6 +808,10 @@ select {
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -1231,6 +1235,19 @@ textarea:disabled) {
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -1392,6 +1409,11 @@ th label {
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-slate-400) {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-slate-500) { :is(.dark .dark\:text-slate-500) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity)); color: rgb(100 116 139 / var(--tw-text-opacity));
@ -1429,6 +1451,10 @@ th label {
padding-right: 1rem; padding-right: 1rem;
} }
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 { .sm\:pl-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
} }

View File

@ -1,4 +1,9 @@
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js"; import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenFalse,
} from "./utils.js";
let syncData = [ let syncData = [
{ {
@ -11,21 +16,28 @@ let syncData = [
syncSelectInputUntilChanged(syncData, "form"); syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
let myConfig = [ disableElementsWhenTrue("#id_type", "game", [
() => { "#id_name",
return getEl("#id_type").value == "game"; "#id_related_purchase",
}, ]);
["#id_name", "#id_related_purchase"], disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
}
]
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig)
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => {
setupElementHandlers();
};
document.body.addEventListener('htmx:beforeRequest', function(event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === 'id_edition') {
var idEditionValue = document.getElementById('id_edition').value;
// Condition to check - replace this with your actual logic
if (idEditionValue != '') {
event.preventDefault(); // This cancels the HTMX request
}
}
});

View File

@ -99,37 +99,72 @@ function getEl(selector) {
return document.getElementsByClassName(selector) return document.getElementsByClassName(selector)
} }
else { else {
return document.getElementsByName(selector) return document.getElementsByTagName(selector)
} }
} }
/** /**
* @description Does something to elements when something happens. * @description Applies different behaviors to elements based on multiple conditional configurations.
* @param {() => boolean} condition The condition that is being tested. * Each configuration is an array containing a condition function, an array of target element selectors,
* @param {string[]} targetElements * and two callback functions for handling matched and unmatched conditions.
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches. * @param {...Array} configs Each configuration is an array of the form:
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match. * - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/ */
function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) { function conditionalElementHandler(...configs) {
if (condition()) { configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
targetElements.forEach((elementName) => { if (condition()) {
let el = getEl(elementName); targetElements.forEach(elementName => {
if (el === null) { let el = getEl(elementName);
console.error("Element ${elementName} doesn't exist."); if (el === null) {
} else { console.error(`Element ${elementName} doesn't exist.`);
callbackfn1(el); } else {
} callbackfn1(el);
}); }
} else { });
targetElements.forEach((elementName) => { } else {
let el = getEl(elementName); targetElements.forEach(elementName => {
if (el === null) { let el = getEl(elementName);
console.error("Element ${elementName} doesn't exist."); if (el === null) {
} else { console.error(`Element ${elementName} doesn't exist.`);
callbackfn2(el); } else {
} callbackfn2(el);
}); }
} });
}
});
} }
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler }; function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value != targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,19 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{% block title %}{{ title }}{% endblock title %} {{ title }}
{% endblock title %}
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<form method="get" class="text-center"> <form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label> <label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2"> <select name="year"
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %} {% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option> <option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %} {% endfor %}
</select> </select>
</form> </form>
@ -62,11 +64,15 @@
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
@ -90,14 +96,13 @@
</thead> </thead>
<tbody> <tbody>
{% for game in top_10_games_by_playtime %} {% for game in top_10_games_by_playtime %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }} <a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game.id %}">{{ game.name }}</a>
</a> </td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> </tr>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -111,10 +116,10 @@
</thead> </thead>
<tbody> <tbody>
{% for item in total_playtime_per_platform %} {% for item in total_playtime_per_platform %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -128,10 +133,13 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in all_finished_this_year %} {% for purchase in all_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <a class="underline decoration-slate-500 sm:decoration-2"
</tr> href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -145,10 +153,13 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in this_year_finished_this_year %} {% for purchase in this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <a class="underline decoration-slate-500 sm:decoration-2"
</tr> href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -162,10 +173,13 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in purchased_this_year_finished_this_year %} {% for purchase in purchased_this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <a class="underline decoration-slate-500 sm:decoration-2"
</tr> href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -180,18 +194,17 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in all_purchased_this_year %} {% for purchase in all_purchased_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}"> <a class="underline decoration-slate-500 sm:decoration-2"
{{ purchase.edition.name }} href="{% url 'edit_purchase' purchase.id %}">
{% if purchase.type != "game" %} {{ purchase.edition.name }}
({{ purchase.name }}, {{ purchase.get_type_display }}) {% if purchase.type != "game" %}({{ purchase.name }}, {{ purchase.get_type_display }}){% endif %}
{% endif %} </a>
</a> </td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td> </tr>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -1,9 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{% block title %}{{ title }}{% endblock title %} {{ title }}
{% endblock title %}
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-4xl flex items-center"> <h1 class="text-4xl flex items-center">
@ -13,88 +12,67 @@
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</h1> </h1>
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span> {{ hours_sum }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span> {{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) </h2> ({{ playrange }})
</h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1> <h1 class="text-3xl mt-4 mb-1">
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1>
<ul> <ul>
{% for edition in editions %} {% for edition in editions %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }}) {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %} {% if edition.wikidata %}
<span class="hidden sm:inline"> <span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}"> <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/> <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" />
</a> </a>
</span> </span>
{% endif %} {% endif %}
{% url 'edit_edition' edition.id as edit_url %} {% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
{% endfor %} <ul>
</ul> {% for purchase in edition.game_purchases %}
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1> <li class="sm:pl-6 flex items-center">
<ul> {{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}
{% for purchase in purchases %} {% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
<li class="sm:pl-2 flex items-center"> {% url 'edit_purchase' purchase.id as edit_url %}
{{ purchase.platform }} {% include 'components/edit_button.html' with edit_url=edit_url %}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}) </li>
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
{% if purchase.related_purchases %}
<li>
<ul> <ul>
{% for related_purchase in purchase.related_purchases %} {% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-6 flex items-center"> <li class="sm:pl-12 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}}) {{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }})
{% url 'edit_purchase' related_purchase.id as edit_url %} {% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</li> {% endfor %}
{% endif %} </ul>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions Sessions
<span class="dark:text-slate-500"> <span class="dark:text-slate-500">({{ sessions.count }})</span>
({{ sessions.count }})
</span>
{% url 'start_game_session' game.id as add_session_link %} {% url 'start_game_session' game.id as add_session_link %}
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %} {% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1> </h1>
<ul> <ul>
{% for session in sessions %} {% for session in sessions %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center">
{{ session.timestamp_start | date:"d/m/Y" }} {{ session.timestamp_start | date:"d/m/Y H:m" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }}) ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %} {% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1">Notes <span class="dark:text-slate-500">({{ sessions_with_notes.count }})</span></h1>
<ul>
{% for session in sessions_with_notes %}
<li class="sm:pl-2">
<ul>
<li class="block dark:text-slate-500">
<span class="flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
{% url 'edit_session' session.id as edit_session_url %}
{% include 'components/edit_button.html' with edit_url=edit_session_url %}
</span>
</li>
<li class="sm:pl-4 italic">
{{ session.note|linebreaks }}
</li>
</ul>
</li>
{% endfor %}
</ul>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -44,6 +44,11 @@ urlpatterns = [
views.add_purchase, views.add_purchase,
name="add_purchase_for_edition", name="add_purchase_for_edition",
), ),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"), path("add-edition/", views.add_edition, name="add_edition"),
path( path(
"add-edition-for-game/<int:game_id>", "add-edition-for-game/<int:game_id>",

View File

@ -1,24 +1,31 @@
from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings from typing import Any, Callable
from django.db.models import Sum, F, Count
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, Prefetch, Sum
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from typing import Callable, Any from django.utils import timezone
from zoneinfo import ZoneInfo
from common.time import format_duration
from common.utils import safe_division
from .forms import ( from .forms import (
DeviceForm,
EditionForm,
GameForm, GameForm,
PlatformForm, PlatformForm,
PurchaseForm, PurchaseForm,
SessionForm, SessionForm,
EditionForm,
DeviceForm,
) )
from .models import Game, Platform, Purchase, Session, Edition from .models import Edition, Game, Platform, Purchase, Session
def model_counts(request): def model_counts(request):
@ -32,19 +39,15 @@ def model_counts(request):
def stats_dropdown_year_range(request): def stats_dropdown_year_range(request):
result = { result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
"stats_dropdown_year_range": range(
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
)
}
return result return result
def add_session(request, purchase_id=None): def add_session(request, purchase_id=None):
context = {} context = {}
initial = {"timestamp_start": now_with_tz()} initial = {"timestamp_start": timezone.now()}
last = Session.objects.all().last() last = Session.objects.last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["purchase"] = last.purchase
@ -136,44 +139,52 @@ def edit_game(request, game_id=None):
def view_game(request, game_id=None): def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
context["title"] = "View Game" nongame_related_purchases_prefetch = Prefetch(
context["game"] = game "related_purchases",
context["editions"] = Edition.objects.filter(game_id=game_id) queryset=Purchase.objects.exclude(type=Purchase.GAME),
game_purchases = ( to_attr="nongame_related_purchases",
Purchase.objects.filter(edition__game_id=game_id) )
.filter(type=Purchase.GAME) game_purchases_prefetch = Prefetch(
.order_by("date_purchased") "purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
) )
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases sessions = Session.objects.filter(purchase__edition__game=game)
context["sessions"] = Session.objects.filter( session_count = sessions.count()
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
)
context["session_average"] = round(
(context["total_hours"]) / int(context["sessions"].count()), 1
)
# here first and last is flipped
# because sessions are ordered from newest to oldest
# so the most recent are on top
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y")
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y")
context["playrange"] = ( playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
playrange = (
playrange_start playrange_start
if playrange_start == playrange_end if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}" else f"{playrange_start}{playrange_end}"
) )
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
}
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@ -204,17 +215,24 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
@use_custom_redirect @use_custom_redirect
def start_game_session(request, game_id: int): def start_game_session(request, game_id: int):
last_session = ( last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(), "timestamp_start": timezone.now(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -227,7 +245,7 @@ def start_session_same_as_last(request, last_session_id: int):
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(), "timestamp_start": timezone.now(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -269,27 +287,29 @@ def list_sessions(
dataset = Session.objects.filter(purchase__ownership_type=ownership_type) dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent": elif filter == "recent":
current_year = datetime.now().year current_year = timezone.now().year
first_day_of_year = datetime(current_year, 1, 1) first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = Session.objects.filter( dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start") ).order_by("-timestamp_start")
context["title"] = "This year" context["title"] = "This year"
else: else:
# by default, sort from newest to oldest # by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start") dataset = Session.objects.order_by("-timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta( if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0 seconds=0
): ):
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) session.timestamp_end = timezone.now()
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted() context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet try:
context["last"] = Session.objects.all().order_by("timestamp_start").last() context["last"] = Session.objects.latest()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@ -299,7 +319,7 @@ def stats(request, year: int = 0):
if selected_year: if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
year = now_with_tz().year year = timezone.now().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year) this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK" selected_currency = "CZK"
unique_days = ( unique_days = (
@ -432,7 +452,7 @@ def stats(request, year: int = 0):
def add_purchase(request, edition_id=None): def add_purchase(request, edition_id=None):
context = {} context = {}
initial = {"date_purchased": now_with_tz()} initial = {"date_purchased": timezone.now()}
if request.method == "POST": if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial) form = PurchaseForm(request.POST or None, initial=initial)

View File

@ -1,7 +0,0 @@
{
"devDependencies": {
"@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.10",
"tailwindcss": "^3.3.3"
}
}

916
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,21 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.0" version = "1.5.1"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.dependencies] [tool.poetry.group.main.dependencies]
python = "^3.10" python = "^3.12"
django = "^4.1.4" django = "^4.2.0"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
pandas = "^1.5.2"
matplotlib = "^3.6.3"
django-rest-framework = "^0.1.0"
django-cors-headers = "^3.13.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^22.12.0"
@ -23,6 +27,10 @@ werkzeug = "^2.2.2"
djhtml = "^1.5.2" djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4" isort = "^5.11.4"
pre-commit = "^3.5.0"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View File

@ -1,19 +0,0 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
darkMode: 'class',
content: ["./games/**/*.{html,js}"],
theme: {
extend: {
fontFamily: {
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
}
},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms')
],
}

View File

@ -1,15 +1,16 @@
import django
import os import os
from django.test import TestCase
from django.urls import reverse
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import django
from django.test import TestCase
from django.urls import reverse
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup() django.setup()
from django.conf import settings from django.conf import settings
from games.models import Game, Edition, Purchase, Session, Platform from games.models import Edition, Game, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE) ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -1,14 +1,16 @@
import django
import os import os
from django.test import TestCase
from django.db import models
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup() django.setup()
from django.conf import settings from django.conf import settings
from games.models import Game, Edition, Purchase, Session
from games.models import Edition, Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE) ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
from corsheaders.defaults import default_headers, default_methods
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -38,13 +39,16 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"rest_framework",
"corsheaders",
] ]
# if DEBUG: if DEBUG:
INSTALLED_APPS.append("django_extensions") INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin") INSTALLED_APPS.append("django.contrib.admin")
MIDDLEWARE = [ MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
@ -123,7 +127,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@ -150,3 +154,24 @@ if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",") CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else: else:
CSRF_TRUSTED_ORIGINS = [] CSRF_TRUSTED_ORIGINS = []
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly"
]
}
FRONTEND_ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "frontend", "dist"))
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = list(default_headers) + [
"Accept-Language",
"Connection",
"Host",
"Origin",
"Referer",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
]

View File

@ -17,12 +17,65 @@ from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from rest_framework import routers, serializers, viewsets
from games.models import Game, Purchase, Platform, Session
class GameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Game
fields = "__all__"
class PlatformSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Platform
fields = "__all__"
class PurchaseSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Purchase
fields = "__all__"
class SessionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Session
fields = "__all__"
class GameViewSet(viewsets.ModelViewSet):
queryset = Game.objects.all()
serializer_class = GameSerializer
class PlatformViewSet(viewsets.ModelViewSet):
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
class PurchaseViewSet(viewsets.ModelViewSet):
queryset = Purchase.objects.all()
serializer_class = PurchaseSerializer
class SessionViewSet(viewsets.ModelViewSet):
queryset = Session.objects.all()
serializer_class = SessionSerializer
router = routers.DefaultRouter()
router.register(r"games", GameViewSet)
router.register(r"platforms", PlatformViewSet)
router.register(r"purchases", PurchaseViewSet)
router.register(r"sessions", SessionViewSet)
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker")), path("api/", include(router.urls)),
path("tracker/", include("games.urls")), path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
] ]
# if settings.DEBUG: if settings.DEBUG:
# urlpatterns.append(path("admin/", admin.site.urls)) urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("admin/", admin.site.urls))