Compare commits
	
		
			75 Commits
		
	
	
		
			0.1.0
			...
			64f5668dde
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 64f5668dde | |||
| 465d958d9b | |||
| d8ece979a8 | |||
| 2defdd4657 | |||
| 078f87687f | |||
| 49723831e9 | |||
| 025ea0dd4e | |||
| 97467c7a52 | |||
| 7842d6f45d | |||
| b77089f7ad | |||
| 24f4459318 | |||
| 751182df52 | |||
| 33e136a810 | |||
| 362732c22a | |||
| 8e1c670ffd | |||
| e5a9b9aa50 | |||
| 
						
						
							
						
						c9b2d5bd8d
	
				 | 
					
					
						|||
| 
						
						
							
						
						0d20b543b0
	
				 | 
					
					
						|||
| 
						
						
							
						
						f7b69f7704
	
				 | 
					
					
						|||
| 
						
						
							
						
						1ccfdc321a
	
				 | 
					
					
						|||
| 
						
						
							
						
						25a58c2732
	
				 | 
					
					
						|||
| 
						
						
							
						
						270d9f7296
	
				 | 
					
					
						|||
| 
						
						
							
						
						2939b4a515
	
				 | 
					
					
						|||
| d029fda896 | |||
| 9dead362c1 | |||
| d81dba727b | |||
| 
						
						
							
						
						f550978e4a
	
				 | 
					
					
						|||
| 
						
						
							
						
						db5de81c09
	
				 | 
					
					
						|||
| 
						
						
							
						
						15ed6504b1
	
				 | 
					
					
						|||
| 
						
						
							
						
						fd9bf8c026
	
				 | 
					
					
						|||
| 
						
						
							
						
						5172c38c16
	
				 | 
					
					
						|||
| 
						
						
							
						
						9c56ed4ce8
	
				 | 
					
					
						|||
| 
						
						
							
						
						d00bb1cd06
	
				 | 
					
					
						|||
| 
						
						
							
						
						bedfbb7f31
	
				 | 
					
					
						|||
| 
						
						
							
						
						f2b08cd1cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						5ad0e52787
	
				 | 
					
					
						|||
| 
						
						
							
						
						f7ec07994f
	
				 | 
					
					
						|||
| 
						
						
							
						
						03e89a92c7
	
				 | 
					
					
						|||
| 
						
						
							
						
						76bf03b482
	
				 | 
					
					
						|||
| 
						
						
							
						
						e6b5804e37
	
				 | 
					
					
						|||
| 
						
						
							
						
						2807c5e00e
	
				 | 
					
					
						|||
| 
						
						
							
						
						8efce77062
	
				 | 
					
					
						|||
| 
						
						
							
						
						89be0c031b
	
				 | 
					
					
						|||
| 
						
						
							
						
						4e67735de8
	
				 | 
					
					
						|||
| 
						
						
							
						
						869e0e0fe0
	
				 | 
					
					
						|||
| 
						
						
							
						
						85f52fc735
	
				 | 
					
					
						|||
| 
						
						
							
						
						34ce1e9b05
	
				 | 
					
					
						|||
| 
						
						
							
						
						67f5090bf8
	
				 | 
					
					
						|||
| 
						
						
							
						
						51d5306f91
	
				 | 
					
					
						|||
| 
						
						
							
						
						66a49ff911
	
				 | 
					
					
						|||
| 
						
						
							
						
						3e32261d4a
	
				 | 
					
					
						|||
| 
						
						
							
						
						9b07758198
	
				 | 
					
					
						|||
| 
						
						
							
						
						c57f969a00
	
				 | 
					
					
						|||
| 
						
						
							
						
						fd7fc7c710
	
				 | 
					
					
						|||
| 
						
						
							
						
						32f10e183e
	
				 | 
					
					
						|||
| 
						
						
							
						
						fdb9aa8e84
	
				 | 
					
					
						|||
| 4b45127335 | |||
| b8a15e43db | |||
| a1309c3738 | |||
| 12cc9025a0 | |||
| 6fe960bc04 | |||
| 
						
						
							
						
						61d2e65d83
	
				 | 
					
					
						|||
| 
						
						
							
						
						84dafe9223
	
				 | 
					
					
						|||
| 
						
						
							
						
						59cf620ff3
	
				 | 
					
					
						|||
| 
						
						
							
						
						40810256aa
	
				 | 
					
					
						|||
| 
						
						
							
						
						b3842504af
	
				 | 
					
					
						|||
| 
						
						
							
						
						bf61326c18
	
				 | 
					
					
						|||
| 
						
						
							
						
						4c642d97cb
	
				 | 
					
					
						|||
| 
						
						
							
						
						d225856174
	
				 | 
					
					
						|||
| 
						
						
							
						
						5c50e059e6
	
				 | 
					
					
						|||
| 84c92fe654 | |||
| 166dd716ed | |||
| 6102459637 | |||
| e4cd75d51f | |||
| b1c8f58855 | 
							
								
								
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					src/web/static/*
 | 
				
			||||||
 | 
					.venv
 | 
				
			||||||
 | 
					.githooks
 | 
				
			||||||
 | 
					.vscode
 | 
				
			||||||
 | 
					node_modules
 | 
				
			||||||
							
								
								
									
										12
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								.drone.yml
									
									
									
									
									
								
							@ -1,18 +1,22 @@
 | 
				
			|||||||
---
 | 
					---
 | 
				
			||||||
kind: pipeline
 | 
					kind: pipeline
 | 
				
			||||||
type: docker
 | 
					type: docker
 | 
				
			||||||
name: build image
 | 
					name: default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
steps:
 | 
					steps:
 | 
				
			||||||
 | 
					- name: test
 | 
				
			||||||
 | 
					  image: python:3.10
 | 
				
			||||||
 | 
					  commands:
 | 
				
			||||||
 | 
					    - python -m pip install poetry
 | 
				
			||||||
 | 
					    - poetry install
 | 
				
			||||||
 | 
					    - poetry env info
 | 
				
			||||||
 | 
					    - poetry run pytest
 | 
				
			||||||
- name: build container
 | 
					- name: build container
 | 
				
			||||||
  image: plugins/docker
 | 
					  image: plugins/docker
 | 
				
			||||||
  settings:
 | 
					  settings:
 | 
				
			||||||
    repo: registry.kucharczyk.xyz/timetracker
 | 
					    repo: registry.kucharczyk.xyz/timetracker
 | 
				
			||||||
    environment:
 | 
					 | 
				
			||||||
      VERSION_NUMBER: $(git describe --tags --abbrev=0)
 | 
					 | 
				
			||||||
    tags:
 | 
					    tags:
 | 
				
			||||||
      - latest
 | 
					      - latest
 | 
				
			||||||
      - $VERSION_NUMBER
 | 
					 | 
				
			||||||
trigger:
 | 
					trigger:
 | 
				
			||||||
  event:
 | 
					  event:
 | 
				
			||||||
  - push
 | 
					  - push
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								.githooks/pre-commit
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										14
									
								
								.githooks/pre-commit
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					set -euo pipefail
 | 
				
			||||||
 | 
					echo "----------------"
 | 
				
			||||||
 | 
					echo "Pre-commit hooks"
 | 
				
			||||||
 | 
					echo "================"
 | 
				
			||||||
 | 
					BASE_VERSION_NUMBER=$(git describe --tags --abbrev=0)
 | 
				
			||||||
 | 
					FULL_VERSION_NUMBER=$(git describe --tags)
 | 
				
			||||||
 | 
					echo "Updating "VERSION_NUMBER" in Dockerfile to $FULL_VERSION_NUMBER"
 | 
				
			||||||
 | 
					sed -i "s/^ENV VERSION_NUMBER.*$/ENV VERSION_NUMBER ${FULL_VERSION_NUMBER}/" Dockerfile
 | 
				
			||||||
 | 
					echo "Updating "version" in pyproject.toml to $BASE_VERSION_NUMBER"
 | 
				
			||||||
 | 
					sed -i "s/^version = \".*\"$/version = \"${BASE_VERSION_NUMBER}\"/" pyproject.toml
 | 
				
			||||||
 | 
					git add Dockerfile
 | 
				
			||||||
 | 
					git add pyproject.toml
 | 
				
			||||||
 | 
					echo "----------------"
 | 
				
			||||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -1,5 +1,8 @@
 | 
				
			|||||||
__pycache__
 | 
					__pycache__
 | 
				
			||||||
 | 
					.mypy_cache
 | 
				
			||||||
 | 
					.pytest_cache
 | 
				
			||||||
.venv
 | 
					.venv
 | 
				
			||||||
node_modules
 | 
					node_modules
 | 
				
			||||||
package-lock.json
 | 
					package-lock.json
 | 
				
			||||||
db.sqlite3
 | 
					db.sqlite3
 | 
				
			||||||
 | 
					src/web/static
 | 
				
			||||||
							
								
								
									
										8
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					    "python.testing.pytestArgs": [
 | 
				
			||||||
 | 
					        "tests"
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    "python.testing.unittestEnabled": false,
 | 
				
			||||||
 | 
					    "python.testing.pytestEnabled": true,
 | 
				
			||||||
 | 
					    "python.analysis.typeCheckingMode": "basic"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										44
									
								
								CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					## 0.2.1 / 2023-01-13 16:53+01:00
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* List number of sessions when filtering on session list
 | 
				
			||||||
 | 
					* Start sessions of last purchase from list (https://git.kucharczyk.xyz/lukas/timetracker/issues/19)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 0.2.0 / 2023-01-09 22:42+01:00
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6)
 | 
				
			||||||
 | 
					* Make formatting durations more robust, change default duration display to "X hours" (https://git.kucharczyk.xyz/lukas/timetracker/issues/26)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 0.1.4 / 2023-01-08 15:45+01:00
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 0.1.3 / 2023-01-08 15:23+01:00
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 0.1.2 / 2023-01-07 22:05+01:00
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Switch to Uvicorn/Gunicorn + Caddy (https://git.kucharczyk.xyz/lukas/timetracker/pulls/4)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 0.1.1 / 2023-01-05 23:26+01:00
 | 
				
			||||||
 | 
					* Order by timestamp_start by default
 | 
				
			||||||
 | 
					* Add pre-commit hook to update version
 | 
				
			||||||
 | 
					* Improve the newcomer experience by guiding through each step
 | 
				
			||||||
 | 
					* Fix errors with empty database
 | 
				
			||||||
 | 
					* Fix negative playtimes being considered positive
 | 
				
			||||||
 | 
					* Add %d for days to common.util.time.format_duration
 | 
				
			||||||
 | 
					* Set up tests, add tests for common.util.time
 | 
				
			||||||
 | 
					* Display total hours played on homepage
 | 
				
			||||||
 | 
					* Add format_duration to common.util.time
 | 
				
			||||||
 | 
					* Allow deleting sessions
 | 
				
			||||||
 | 
					* Redirect after adding game/platform/purchase/session
 | 
				
			||||||
 | 
					* Fix display of duration_manual
 | 
				
			||||||
 | 
					* Fix display of duration_calculated, display durations less than a minute
 | 
				
			||||||
 | 
					* Make the "Finish now?" button on session list work
 | 
				
			||||||
 | 
					* Hide navigation bar items if there are no games/purchases/sessions
 | 
				
			||||||
 | 
					* Set default version to "git-main" to indicate development environment
 | 
				
			||||||
 | 
					* Add homepage, link to it from the logo
 | 
				
			||||||
 | 
					* Make it possible to add a new platform
 | 
				
			||||||
 | 
					* Save calculated duration to database if both timestamps are set
 | 
				
			||||||
 | 
					* Improve session listing
 | 
				
			||||||
 | 
					* Set version in the footer to fixed, fix main container height
 | 
				
			||||||
							
								
								
									
										14
									
								
								Caddyfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								Caddyfile
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					    auto_https off
 | 
				
			||||||
 | 
					    admin off
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					:8000 {
 | 
				
			||||||
 | 
					    handle_path /static/* {
 | 
				
			||||||
 | 
					        root * src/web/static/
 | 
				
			||||||
 | 
					        file_server
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    handle {
 | 
				
			||||||
 | 
					        reverse_proxy :8001
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										30
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								Dockerfile
									
									
									
									
									
								
							@ -1,15 +1,31 @@
 | 
				
			|||||||
FROM python:3.10-slim-bullseye
 | 
					FROM node as css
 | 
				
			||||||
ENV VIRTUAL_ENV=/opt/venv
 | 
					WORKDIR /app
 | 
				
			||||||
RUN python3 -m venv pip $VIRTUAL_ENV
 | 
					COPY . /app
 | 
				
			||||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
 | 
					RUN npm install && \
 | 
				
			||||||
RUN pip install --no-cache-dir poetry
 | 
					    npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify
 | 
				
			||||||
RUN useradd --create-home --uid 1000 timetracker
 | 
					
 | 
				
			||||||
 | 
					FROM python:3.10.9-alpine
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENV VERSION_NUMBER 0.2.1
 | 
				
			||||||
 | 
					ENV PROD 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN apk add \
 | 
				
			||||||
 | 
					    bash \
 | 
				
			||||||
 | 
					    vim \
 | 
				
			||||||
 | 
					    curl \
 | 
				
			||||||
 | 
					    caddy
 | 
				
			||||||
 | 
					RUN adduser -D -u 1000 timetracker
 | 
				
			||||||
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
 | 
				
			||||||
RUN poetry install --without dev
 | 
					COPY --from=css /app/src/web/tracker/static/base.css /home/timetracker/app/src/web/tracker/static/base.css
 | 
				
			||||||
COPY entrypoint.sh /
 | 
					COPY entrypoint.sh /
 | 
				
			||||||
RUN chmod +x /entrypoint.sh
 | 
					RUN chmod +x /entrypoint.sh
 | 
				
			||||||
 | 
					
 | 
				
			||||||
USER timetracker
 | 
					USER timetracker
 | 
				
			||||||
 | 
					ENV PATH="$PATH:/home/timetracker/.local/bin"
 | 
				
			||||||
 | 
					RUN pip install --no-cache-dir poetry
 | 
				
			||||||
 | 
					RUN poetry install --without dev
 | 
				
			||||||
 | 
					
 | 
				
			||||||
EXPOSE 8000
 | 
					EXPOSE 8000
 | 
				
			||||||
ENTRYPOINT [ "/entrypoint.sh" ]
 | 
					ENTRYPOINT [ "/entrypoint.sh" ]
 | 
				
			||||||
							
								
								
									
										49
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								Makefile
									
									
									
									
									
								
							@ -1,8 +1,6 @@
 | 
				
			|||||||
.PHONY: createsuperuser
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
all: css migrate
 | 
					all: css migrate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
initialize: npm css migrate loadplatforms
 | 
					initialize: npm css migrate sethookdir loadplatforms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
HTMLFILES := $(shell find src/web/tracker/templates -type f)
 | 
					HTMLFILES := $(shell find src/web/tracker/templates -type f)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -16,22 +14,51 @@ css-dev: css
 | 
				
			|||||||
	npx tailwindcss -i ./src/input.css -o  ./src/web/tracker/static/base.css --watch
 | 
						npx tailwindcss -i ./src/input.css -o  ./src/web/tracker/static/base.css --watch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
makemigrations:
 | 
					makemigrations:
 | 
				
			||||||
	python src/web/manage.py makemigrations
 | 
						poetry run python src/web/manage.py makemigrations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
migrate: makemigrations
 | 
					migrate: makemigrations
 | 
				
			||||||
	python src/web/manage.py migrate
 | 
						poetry run python src/web/manage.py migrate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
dev: migrate
 | 
					dev: migrate sethookdir
 | 
				
			||||||
	python src/web/manage.py runserver
 | 
						poetry run python src/web/manage.py runserver
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					caddy:
 | 
				
			||||||
 | 
						caddy run --watch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					dev-prod: migrate collectstatic sethookdir
 | 
				
			||||||
 | 
						cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
dumptracker:
 | 
					dumptracker:
 | 
				
			||||||
	python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml
 | 
						poetry run python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
loadplatforms:
 | 
					loadplatforms:
 | 
				
			||||||
	python src/web/manage.py loaddata platforms.yaml
 | 
						poetry run python src/web/manage.py loaddata platforms.yaml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
loadsample:
 | 
					loadsample:
 | 
				
			||||||
	python src/web/manage.py loaddata sample.yaml
 | 
						poetry run python src/web/manage.py loaddata sample.yaml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
createsuperuser:
 | 
					createsuperuser:
 | 
				
			||||||
	python src/web/manage.py createsuperuser
 | 
						poetry run python src/web/manage.py createsuperuser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					shell:
 | 
				
			||||||
 | 
						poetry run python src/web/manage.py shell
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					collectstatic:
 | 
				
			||||||
 | 
						poetry run python src/web/manage.py collectstatic --clear --no-input
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					poetry.lock: pyproject.toml
 | 
				
			||||||
 | 
						poetry install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test: poetry.lock
 | 
				
			||||||
 | 
						poetry run pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sethookdir:
 | 
				
			||||||
 | 
						git config core.hooksPath .githooks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					date:
 | 
				
			||||||
 | 
						poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					cleanstatic:
 | 
				
			||||||
 | 
						rm -r src/web/static/*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					clean: cleanstatic
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										17
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					---
 | 
				
			||||||
 | 
					services:
 | 
				
			||||||
 | 
					  timetracker:
 | 
				
			||||||
 | 
					    image: registry.kucharczyk.xyz/timetracker
 | 
				
			||||||
 | 
					    build:
 | 
				
			||||||
 | 
					      context: .
 | 
				
			||||||
 | 
					      dockerfile: Dockerfile
 | 
				
			||||||
 | 
					    container_name: timetracker
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      - TZ=Europe/Prague
 | 
				
			||||||
 | 
					      - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
 | 
				
			||||||
 | 
					    user: "1000"
 | 
				
			||||||
 | 
					    # volumes:
 | 
				
			||||||
 | 
					    #   - "db:/home/timetracker/app/src/web/db.sqlite3"
 | 
				
			||||||
 | 
					    ports:
 | 
				
			||||||
 | 
					      - "8000:8000"
 | 
				
			||||||
 | 
					    restart: unless-stopped
 | 
				
			||||||
@ -1,8 +1,13 @@
 | 
				
			|||||||
#!/bin/bash
 | 
					#!/bin/bash
 | 
				
			||||||
# Apply database migrations
 | 
					# Apply database migrations
 | 
				
			||||||
 | 
					set -euo pipefail
 | 
				
			||||||
echo "Apply database migrations"
 | 
					echo "Apply database migrations"
 | 
				
			||||||
python src/web/manage.py migrate
 | 
					poetry run python src/web/manage.py migrate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "Collect static files"
 | 
				
			||||||
 | 
					poetry run python src/web/manage.py collectstatic --clear --no-input
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Start server
 | 
					 | 
				
			||||||
echo "Starting server"
 | 
					echo "Starting server"
 | 
				
			||||||
python src/web/manage.py runserver 0.0.0.0:8000
 | 
					caddy start
 | 
				
			||||||
 | 
					cd src/web || exit
 | 
				
			||||||
 | 
					poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										536
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										536
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							@ -15,6 +15,25 @@ files = [
 | 
				
			|||||||
[package.extras]
 | 
					[package.extras]
 | 
				
			||||||
tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
 | 
					tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "attrs"
 | 
				
			||||||
 | 
					version = "22.2.0"
 | 
				
			||||||
 | 
					description = "Classes Without Boilerplate"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.6"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
 | 
				
			||||||
 | 
					    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
 | 
				
			||||||
 | 
					dev = ["attrs[docs,tests]"]
 | 
				
			||||||
 | 
					docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
 | 
				
			||||||
 | 
					tests = ["attrs[tests-no-zope]", "zope.interface"]
 | 
				
			||||||
 | 
					tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "black"
 | 
					name = "black"
 | 
				
			||||||
version = "22.12.0"
 | 
					version = "22.12.0"
 | 
				
			||||||
@ -54,7 +73,7 @@ uvloop = ["uvloop (>=0.15.2)"]
 | 
				
			|||||||
name = "click"
 | 
					name = "click"
 | 
				
			||||||
version = "8.1.3"
 | 
					version = "8.1.3"
 | 
				
			||||||
description = "Composable command line interface toolkit"
 | 
					description = "Composable command line interface toolkit"
 | 
				
			||||||
category = "dev"
 | 
					category = "main"
 | 
				
			||||||
optional = false
 | 
					optional = false
 | 
				
			||||||
python-versions = ">=3.7"
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
files = [
 | 
					files = [
 | 
				
			||||||
@ -69,7 +88,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
 | 
				
			|||||||
name = "colorama"
 | 
					name = "colorama"
 | 
				
			||||||
version = "0.4.6"
 | 
					version = "0.4.6"
 | 
				
			||||||
description = "Cross-platform colored terminal text."
 | 
					description = "Cross-platform colored terminal text."
 | 
				
			||||||
category = "dev"
 | 
					category = "main"
 | 
				
			||||||
optional = false
 | 
					optional = false
 | 
				
			||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 | 
					python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 | 
				
			||||||
files = [
 | 
					files = [
 | 
				
			||||||
@ -77,16 +96,32 @@ files = [
 | 
				
			|||||||
    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 | 
					    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "cssbeautifier"
 | 
				
			||||||
 | 
					version = "1.14.7"
 | 
				
			||||||
 | 
					description = "CSS unobfuscator and beautifier."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "cssbeautifier-1.14.7.tar.gz", hash = "sha256:be7f1ea7a7b009f0172c2c0d0bebb2d136346e786f7182185ea944affb52135a"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					editorconfig = ">=0.12.2"
 | 
				
			||||||
 | 
					jsbeautifier = "*"
 | 
				
			||||||
 | 
					six = ">=1.13.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "django"
 | 
					name = "django"
 | 
				
			||||||
version = "4.1.4"
 | 
					version = "4.1.5"
 | 
				
			||||||
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
 | 
					description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
 | 
				
			||||||
category = "main"
 | 
					category = "main"
 | 
				
			||||||
optional = false
 | 
					optional = false
 | 
				
			||||||
python-versions = ">=3.8"
 | 
					python-versions = ">=3.8"
 | 
				
			||||||
files = [
 | 
					files = [
 | 
				
			||||||
    {file = "Django-4.1.4-py3-none-any.whl", hash = "sha256:0b223bfa55511f950ff741983d408d78d772351284c75e9f77d2b830b6b4d148"},
 | 
					    {file = "Django-4.1.5-py3-none-any.whl", hash = "sha256:4b214a05fe4c99476e99e2445c8b978c8369c18d4dea8e22ec412862715ad763"},
 | 
				
			||||||
    {file = "Django-4.1.4.tar.gz", hash = "sha256:d38a4e108d2386cb9637da66a82dc8d0733caede4c83c4afdbda78af4214211b"},
 | 
					    {file = "Django-4.1.5.tar.gz", hash = "sha256:ff56ebd7ead0fd5dbe06fe157b0024a7aaea2e0593bb3785fb594cf94dad58ef"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[package.dependencies]
 | 
					[package.dependencies]
 | 
				
			||||||
@ -98,6 +133,242 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
 | 
				
			|||||||
argon2 = ["argon2-cffi (>=19.1.0)"]
 | 
					argon2 = ["argon2-cffi (>=19.1.0)"]
 | 
				
			||||||
bcrypt = ["bcrypt"]
 | 
					bcrypt = ["bcrypt"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "django-extensions"
 | 
				
			||||||
 | 
					version = "3.2.1"
 | 
				
			||||||
 | 
					description = "Extensions for Django"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.6"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "django-extensions-3.2.1.tar.gz", hash = "sha256:2a4f4d757be2563cd1ff7cfdf2e57468f5f931cc88b23cf82ca75717aae504a4"},
 | 
				
			||||||
 | 
					    {file = "django_extensions-3.2.1-py3-none-any.whl", hash = "sha256:421464be390289513f86cb5e18eb43e5dc1de8b4c27ba9faa3b91261b0d67e09"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					Django = ">=3.2"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "djhtml"
 | 
				
			||||||
 | 
					version = "1.5.2"
 | 
				
			||||||
 | 
					description = "Django/Jinja template indenter"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "djhtml-1.5.2.tar.gz", hash = "sha256:b54c4ab6effaf3dbe87d616ba30304f1dba22f07127a563df4130a71acc290ea"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					dev = ["black", "flake8", "isort", "nox", "pre-commit"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "djlint"
 | 
				
			||||||
 | 
					version = "1.19.11"
 | 
				
			||||||
 | 
					description = "HTML Template Linter and Formatter"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7.2,<4.0.0"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "djlint-1.19.11-py3-none-any.whl", hash = "sha256:c19d732c79b660f7d406517254bfd3e98f6c3c2de0cb300766b5555077d4ae08"},
 | 
				
			||||||
 | 
					    {file = "djlint-1.19.11.tar.gz", hash = "sha256:98bbe094a6f176258a578b16c492cbbb9384e3a3b447c9241ee4a6427d703402"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					click = ">=8.0.1,<9.0.0"
 | 
				
			||||||
 | 
					colorama = ">=0.4.4,<0.5.0"
 | 
				
			||||||
 | 
					cssbeautifier = ">=1.14.4,<2.0.0"
 | 
				
			||||||
 | 
					html-tag-names = ">=0.1.2,<0.2.0"
 | 
				
			||||||
 | 
					html-void-elements = ">=0.1.0,<0.2.0"
 | 
				
			||||||
 | 
					importlib-metadata = ">=6.0.0,<7.0.0"
 | 
				
			||||||
 | 
					jsbeautifier = ">=1.14.4,<2.0.0"
 | 
				
			||||||
 | 
					pathspec = ">=0.10.0,<0.11.0"
 | 
				
			||||||
 | 
					PyYAML = ">=6.0,<7.0"
 | 
				
			||||||
 | 
					regex = ">=2022.1.18,<2023.0.0"
 | 
				
			||||||
 | 
					tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""}
 | 
				
			||||||
 | 
					tqdm = ">=4.62.2,<5.0.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "editorconfig"
 | 
				
			||||||
 | 
					version = "0.12.3"
 | 
				
			||||||
 | 
					description = "EditorConfig File Locator and Interpreter for Python"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "EditorConfig-0.12.3-py3-none-any.whl", hash = "sha256:6b0851425aa875b08b16789ee0eeadbd4ab59666e9ebe728e526314c4a2e52c1"},
 | 
				
			||||||
 | 
					    {file = "EditorConfig-0.12.3.tar.gz", hash = "sha256:57f8ce78afcba15c8b18d46b5170848c88d56fd38f05c2ec60dbbfcb8996e89e"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "exceptiongroup"
 | 
				
			||||||
 | 
					version = "1.1.0"
 | 
				
			||||||
 | 
					description = "Backport of PEP 654 (exception groups)"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
 | 
				
			||||||
 | 
					    {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					test = ["pytest (>=6)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "gunicorn"
 | 
				
			||||||
 | 
					version = "20.1.0"
 | 
				
			||||||
 | 
					description = "WSGI HTTP Server for UNIX"
 | 
				
			||||||
 | 
					category = "main"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.5"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"},
 | 
				
			||||||
 | 
					    {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					setuptools = ">=3.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					eventlet = ["eventlet (>=0.24.1)"]
 | 
				
			||||||
 | 
					gevent = ["gevent (>=1.4.0)"]
 | 
				
			||||||
 | 
					setproctitle = ["setproctitle"]
 | 
				
			||||||
 | 
					tornado = ["tornado (>=0.2)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "h11"
 | 
				
			||||||
 | 
					version = "0.14.0"
 | 
				
			||||||
 | 
					description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 | 
				
			||||||
 | 
					category = "main"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
 | 
				
			||||||
 | 
					    {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "html-tag-names"
 | 
				
			||||||
 | 
					version = "0.1.2"
 | 
				
			||||||
 | 
					description = "List of known HTML tag names"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7,<4.0"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "html-tag-names-0.1.2.tar.gz", hash = "sha256:04924aca48770f36b5a41c27e4d917062507be05118acb0ba869c97389084297"},
 | 
				
			||||||
 | 
					    {file = "html_tag_names-0.1.2-py3-none-any.whl", hash = "sha256:eeb69ef21078486b615241f0393a72b41352c5219ee648e7c61f5632d26f0420"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "html-void-elements"
 | 
				
			||||||
 | 
					version = "0.1.0"
 | 
				
			||||||
 | 
					description = "List of HTML void tag names."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7,<4.0"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "html-void-elements-0.1.0.tar.gz", hash = "sha256:931b88f84cd606fee0b582c28fcd00e41d7149421fb673e1e1abd2f0c4f231f0"},
 | 
				
			||||||
 | 
					    {file = "html_void_elements-0.1.0-py3-none-any.whl", hash = "sha256:784cf39db03cdeb017320d9301009f8f3480f9d7b254d0974272e80e0cb5e0d2"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "importlib-metadata"
 | 
				
			||||||
 | 
					version = "6.0.0"
 | 
				
			||||||
 | 
					description = "Read metadata from Python packages"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"},
 | 
				
			||||||
 | 
					    {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					zipp = ">=0.5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 | 
				
			||||||
 | 
					perf = ["ipython"]
 | 
				
			||||||
 | 
					testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "iniconfig"
 | 
				
			||||||
 | 
					version = "1.1.1"
 | 
				
			||||||
 | 
					description = "iniconfig: brain-dead simple config-ini parsing"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
 | 
				
			||||||
 | 
					    {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "jsbeautifier"
 | 
				
			||||||
 | 
					version = "1.14.7"
 | 
				
			||||||
 | 
					description = "JavaScript unobfuscator and beautifier."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "jsbeautifier-1.14.7.tar.gz", hash = "sha256:77993254db1ff6f84eb6e1d75e3b6b72cba2ef20813a585b2d81e8e5e3c713c6"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					editorconfig = ">=0.12.2"
 | 
				
			||||||
 | 
					six = ">=1.13.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "markupsafe"
 | 
				
			||||||
 | 
					version = "2.1.1"
 | 
				
			||||||
 | 
					description = "Safely add untrusted strings to HTML/XML markup."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "mypy"
 | 
					name = "mypy"
 | 
				
			||||||
version = "0.991"
 | 
					version = "0.991"
 | 
				
			||||||
@ -161,6 +432,18 @@ files = [
 | 
				
			|||||||
    {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
 | 
					    {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "packaging"
 | 
				
			||||||
 | 
					version = "22.0"
 | 
				
			||||||
 | 
					description = "Core utilities for Python packages"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"},
 | 
				
			||||||
 | 
					    {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "pathspec"
 | 
					name = "pathspec"
 | 
				
			||||||
version = "0.10.3"
 | 
					version = "0.10.3"
 | 
				
			||||||
@ -189,6 +472,46 @@ files = [
 | 
				
			|||||||
docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
 | 
					docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
 | 
				
			||||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
 | 
					test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "pluggy"
 | 
				
			||||||
 | 
					version = "1.0.0"
 | 
				
			||||||
 | 
					description = "plugin and hook calling mechanisms for python"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.6"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
 | 
				
			||||||
 | 
					    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					dev = ["pre-commit", "tox"]
 | 
				
			||||||
 | 
					testing = ["pytest", "pytest-benchmark"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "pytest"
 | 
				
			||||||
 | 
					version = "7.2.0"
 | 
				
			||||||
 | 
					description = "pytest: simple powerful testing with Python"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
 | 
				
			||||||
 | 
					    {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					attrs = ">=19.2.0"
 | 
				
			||||||
 | 
					colorama = {version = "*", markers = "sys_platform == \"win32\""}
 | 
				
			||||||
 | 
					exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
 | 
				
			||||||
 | 
					iniconfig = "*"
 | 
				
			||||||
 | 
					packaging = "*"
 | 
				
			||||||
 | 
					pluggy = ">=0.12,<2.0"
 | 
				
			||||||
 | 
					tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "pyyaml"
 | 
					name = "pyyaml"
 | 
				
			||||||
version = "6.0"
 | 
					version = "6.0"
 | 
				
			||||||
@ -239,6 +562,133 @@ files = [
 | 
				
			|||||||
    {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
 | 
					    {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "regex"
 | 
				
			||||||
 | 
					version = "2022.10.31"
 | 
				
			||||||
 | 
					description = "Alternative regular expression module, to replace re."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.6"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-win32.whl", hash = "sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp310-cp310-win_amd64.whl", hash = "sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-win32.whl", hash = "sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp311-cp311-win_amd64.whl", hash = "sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-win32.whl", hash = "sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp36-cp36m-win_amd64.whl", hash = "sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-win32.whl", hash = "sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp37-cp37m-win_amd64.whl", hash = "sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-win32.whl", hash = "sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp38-cp38-win_amd64.whl", hash = "sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-win32.whl", hash = "sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31-cp39-cp39-win_amd64.whl", hash = "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692"},
 | 
				
			||||||
 | 
					    {file = "regex-2022.10.31.tar.gz", hash = "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "setuptools"
 | 
				
			||||||
 | 
					version = "65.6.3"
 | 
				
			||||||
 | 
					description = "Easily download, build, install, upgrade, and uninstall Python packages"
 | 
				
			||||||
 | 
					category = "main"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"},
 | 
				
			||||||
 | 
					    {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
 | 
				
			||||||
 | 
					testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
 | 
				
			||||||
 | 
					testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "six"
 | 
				
			||||||
 | 
					version = "1.16.0"
 | 
				
			||||||
 | 
					description = "Python 2 and 3 compatibility utilities"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
 | 
				
			||||||
 | 
					    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "sqlparse"
 | 
					name = "sqlparse"
 | 
				
			||||||
version = "0.4.3"
 | 
					version = "0.4.3"
 | 
				
			||||||
@ -263,6 +713,27 @@ files = [
 | 
				
			|||||||
    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
 | 
					    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "tqdm"
 | 
				
			||||||
 | 
					version = "4.64.1"
 | 
				
			||||||
 | 
					description = "Fast, Extensible Progress Meter"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "tqdm-4.64.1-py2.py3-none-any.whl", hash = "sha256:6fee160d6ffcd1b1c68c65f14c829c22832bc401726335ce92c52d395944a6a1"},
 | 
				
			||||||
 | 
					    {file = "tqdm-4.64.1.tar.gz", hash = "sha256:5f4f682a004951c1b450bc753c710e9280c5746ce6ffedee253ddbcbf54cf1e4"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					colorama = {version = "*", markers = "platform_system == \"Windows\""}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					dev = ["py-make (>=0.1.0)", "twine", "wheel"]
 | 
				
			||||||
 | 
					notebook = ["ipywidgets (>=6)"]
 | 
				
			||||||
 | 
					slack = ["slack-sdk"]
 | 
				
			||||||
 | 
					telegram = ["requests"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "typing-extensions"
 | 
					name = "typing-extensions"
 | 
				
			||||||
version = "4.4.0"
 | 
					version = "4.4.0"
 | 
				
			||||||
@ -287,7 +758,60 @@ files = [
 | 
				
			|||||||
    {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"},
 | 
					    {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "uvicorn"
 | 
				
			||||||
 | 
					version = "0.20.0"
 | 
				
			||||||
 | 
					description = "The lightning-fast ASGI server."
 | 
				
			||||||
 | 
					category = "main"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "uvicorn-0.20.0-py3-none-any.whl", hash = "sha256:c3ed1598a5668208723f2bb49336f4509424ad198d6ab2615b7783db58d919fd"},
 | 
				
			||||||
 | 
					    {file = "uvicorn-0.20.0.tar.gz", hash = "sha256:a4e12017b940247f836bc90b72e725d7dfd0c8ed1c51eb365f5ba30d9f5127d8"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					click = ">=7.0"
 | 
				
			||||||
 | 
					h11 = ">=0.8"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "werkzeug"
 | 
				
			||||||
 | 
					version = "2.2.2"
 | 
				
			||||||
 | 
					description = "The comprehensive WSGI web application library."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "Werkzeug-2.2.2-py3-none-any.whl", hash = "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5"},
 | 
				
			||||||
 | 
					    {file = "Werkzeug-2.2.2.tar.gz", hash = "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					MarkupSafe = ">=2.1.1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					watchdog = ["watchdog"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "zipp"
 | 
				
			||||||
 | 
					version = "3.11.0"
 | 
				
			||||||
 | 
					description = "Backport of pathlib-compatible object wrapper for zip files"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"},
 | 
				
			||||||
 | 
					    {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
 | 
				
			||||||
 | 
					testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[metadata]
 | 
					[metadata]
 | 
				
			||||||
lock-version = "2.0"
 | 
					lock-version = "2.0"
 | 
				
			||||||
python-versions = "^3.10"
 | 
					python-versions = "^3.10"
 | 
				
			||||||
content-hash = "0c8d59942dd82c7e89746cfdce544794bf1ce317cac4cdb7b2b5a9137001131d"
 | 
					content-hash = "fd85e51c8fb99824a433b451c9712b7418c13688b9eb0e8ca6c51768f544e48f"
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
[tool.poetry]
 | 
					[tool.poetry]
 | 
				
			||||||
name = "timetracker"
 | 
					name = "timetracker"
 | 
				
			||||||
version = "0.0.0"
 | 
					version = "0.2.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"
 | 
				
			||||||
@ -9,12 +9,24 @@ readme = "README.md"
 | 
				
			|||||||
[tool.poetry.dependencies]
 | 
					[tool.poetry.dependencies]
 | 
				
			||||||
python = "^3.10"
 | 
					python = "^3.10"
 | 
				
			||||||
django = "^4.1.4"
 | 
					django = "^4.1.4"
 | 
				
			||||||
 | 
					gunicorn = "^20.1.0"
 | 
				
			||||||
 | 
					uvicorn = "^0.20.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[tool.poetry.group.dev.dependencies]
 | 
					[tool.poetry.group.dev.dependencies]
 | 
				
			||||||
black = "^22.12.0"
 | 
					black = "^22.12.0"
 | 
				
			||||||
mypy = "^0.991"
 | 
					mypy = "^0.991"
 | 
				
			||||||
pyyaml = "^6.0"
 | 
					pyyaml = "^6.0"
 | 
				
			||||||
 | 
					pytest = "^7.2.0"
 | 
				
			||||||
 | 
					django-extensions = "^3.2.1"
 | 
				
			||||||
 | 
					werkzeug = "^2.2.2"
 | 
				
			||||||
 | 
					djhtml = "^1.5.2"
 | 
				
			||||||
 | 
					djlint = "^1.19.11"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[build-system]
 | 
					[build-system]
 | 
				
			||||||
requires = ["poetry-core"]
 | 
					requires = ["poetry-core"]
 | 
				
			||||||
build-backend = "poetry.core.masonry.api"
 | 
					build-backend = "poetry.core.masonry.api"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[tool.pytest.ini_options]
 | 
				
			||||||
 | 
					pythonpath = [
 | 
				
			||||||
 | 
					  "src"
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
				
			|||||||
@ -3,13 +3,20 @@
 | 
				
			|||||||
@tailwind utilities;
 | 
					@tailwind utilities;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
form label {
 | 
					form label {
 | 
				
			||||||
    @apply dark:text-slate-400;
 | 
					  @apply dark:text-slate-400;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
form input,select,textarea {
 | 
					form input,
 | 
				
			||||||
    @apply dark:bg-slate-500 dark:border dark:border-slate-900 dark:text-slate-100;
 | 
					select,
 | 
				
			||||||
 | 
					textarea {
 | 
				
			||||||
 | 
					  @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
form input[type=submit] {
 | 
					#session-table {
 | 
				
			||||||
    @apply p-2 bg-purple-900;
 | 
					  display: grid;
 | 
				
			||||||
}
 | 
					  grid-template-columns: repeat(3, 2fr) 0.5fr 1fr;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#button-container button {
 | 
				
			||||||
 | 
					  @apply mx-1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										0
									
								
								src/web/common/util/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/web/common/util/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										62
									
								
								src/web/common/util/time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/web/common/util/time.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					from datetime import datetime, timedelta
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from zoneinfo import ZoneInfo
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def now() -> datetime:
 | 
				
			||||||
 | 
					    return datetime.now(ZoneInfo(settings.TIME_ZONE))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _safe_timedelta(duration: timedelta | int | None):
 | 
				
			||||||
 | 
					    if duration == None:
 | 
				
			||||||
 | 
					        return timedelta(0)
 | 
				
			||||||
 | 
					    elif isinstance(duration, int):
 | 
				
			||||||
 | 
					        return timedelta(seconds=duration)
 | 
				
			||||||
 | 
					    elif isinstance(duration, timedelta):
 | 
				
			||||||
 | 
					        return duration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def format_duration(
 | 
				
			||||||
 | 
					    duration: timedelta | int | None, format_string: str = "%H hours"
 | 
				
			||||||
 | 
					) -> str:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Format timedelta into the specified format_string.
 | 
				
			||||||
 | 
					    Valid format variables:
 | 
				
			||||||
 | 
					    - %H hours
 | 
				
			||||||
 | 
					    - %m minutes
 | 
				
			||||||
 | 
					    - %s seconds
 | 
				
			||||||
 | 
					    - %r total seconds
 | 
				
			||||||
 | 
					    Values don't change into higher units if those units are missing
 | 
				
			||||||
 | 
					    from the formatting string. For example:
 | 
				
			||||||
 | 
					    - 61 seconds as "%s" = 61 seconds
 | 
				
			||||||
 | 
					    - 61 seconds as "%m %s" = 1 minutes 1 seconds"
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    minute_seconds = 60
 | 
				
			||||||
 | 
					    hour_seconds = 60 * minute_seconds
 | 
				
			||||||
 | 
					    day_seconds = 24 * hour_seconds
 | 
				
			||||||
 | 
					    duration = _safe_timedelta(duration)
 | 
				
			||||||
 | 
					    # we don't need float
 | 
				
			||||||
 | 
					    seconds_total = int(duration.total_seconds())
 | 
				
			||||||
 | 
					    # timestamps where end is before start
 | 
				
			||||||
 | 
					    if seconds_total < 0:
 | 
				
			||||||
 | 
					        seconds_total = 0
 | 
				
			||||||
 | 
					    days = hours = minutes = seconds = 0
 | 
				
			||||||
 | 
					    remainder = seconds = seconds_total
 | 
				
			||||||
 | 
					    if "%d" in format_string:
 | 
				
			||||||
 | 
					        days, remainder = divmod(seconds_total, day_seconds)
 | 
				
			||||||
 | 
					    if "%H" in format_string:
 | 
				
			||||||
 | 
					        hours, remainder = divmod(remainder, hour_seconds)
 | 
				
			||||||
 | 
					    if "%m" in format_string:
 | 
				
			||||||
 | 
					        minutes, seconds = divmod(remainder, minute_seconds)
 | 
				
			||||||
 | 
					    literals = {
 | 
				
			||||||
 | 
					        "%d": str(days),
 | 
				
			||||||
 | 
					        "%H": str(hours),
 | 
				
			||||||
 | 
					        "%m": str(minutes),
 | 
				
			||||||
 | 
					        "%s": str(seconds),
 | 
				
			||||||
 | 
					        "%r": str(seconds_total),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    formatted_string = format_string
 | 
				
			||||||
 | 
					    for pattern, replacement in literals.items():
 | 
				
			||||||
 | 
					        formatted_string = re.sub(pattern, replacement, formatted_string)
 | 
				
			||||||
 | 
					    return formatted_string
 | 
				
			||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
from .models import Session, Purchase, Game
 | 
					from .models import Session, Purchase, Game, Platform
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SessionForm(forms.ModelForm):
 | 
					class SessionForm(forms.ModelForm):
 | 
				
			||||||
@ -24,3 +24,9 @@ class GameForm(forms.ModelForm):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = Game
 | 
					        model = Game
 | 
				
			||||||
        fields = ["name", "wikidata"]
 | 
					        fields = ["name", "wikidata"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PlatformForm(forms.ModelForm):
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = Platform
 | 
				
			||||||
 | 
					        fields = ["name", "group"]
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.1.5 on 2023-01-09 14:49
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import datetime
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("tracker", "0003_alter_session_duration_manual_and_more"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="session",
 | 
				
			||||||
 | 
					            name="duration_manual",
 | 
				
			||||||
 | 
					            field=models.DurationField(
 | 
				
			||||||
 | 
					                blank=True, default=datetime.timedelta(0), null=True
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										34
									
								
								src/web/tracker/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/web/tracker/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.1.5 on 2023-01-09 17:43
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def set_duration_calculated_none_to_zero(apps, schema_editor):
 | 
				
			||||||
 | 
					    Session = apps.get_model("tracker", "Session")
 | 
				
			||||||
 | 
					    for session in Session.objects.all():
 | 
				
			||||||
 | 
					        if session.duration_calculated == None:
 | 
				
			||||||
 | 
					            session.duration_calculated = timedelta(0)
 | 
				
			||||||
 | 
					            session.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
 | 
				
			||||||
 | 
					    Session = apps.get_model("tracker", "Session")
 | 
				
			||||||
 | 
					    for session in Session.objects.all():
 | 
				
			||||||
 | 
					        if session.duration_calculated == timedelta(0):
 | 
				
			||||||
 | 
					            session.duration_calculated = None
 | 
				
			||||||
 | 
					            session.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("tracker", "0004_alter_session_duration_manual"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RunPython(
 | 
				
			||||||
 | 
					            set_duration_calculated_none_to_zero,
 | 
				
			||||||
 | 
					            revert_set_duration_calculated_none_to_zero,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										34
									
								
								src/web/tracker/migrations/0006_auto_20230109_1904.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/web/tracker/migrations/0006_auto_20230109_1904.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.1.5 on 2023-01-09 18:04
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def set_duration_manual_none_to_zero(apps, schema_editor):
 | 
				
			||||||
 | 
					    Session = apps.get_model("tracker", "Session")
 | 
				
			||||||
 | 
					    for session in Session.objects.all():
 | 
				
			||||||
 | 
					        if session.duration_manual == None:
 | 
				
			||||||
 | 
					            session.duration_manual = timedelta(0)
 | 
				
			||||||
 | 
					            session.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def revert_set_duration_manual_none_to_zero(apps, schema_editor):
 | 
				
			||||||
 | 
					    Session = apps.get_model("tracker", "Session")
 | 
				
			||||||
 | 
					    for session in Session.objects.all():
 | 
				
			||||||
 | 
					        if session.duration_manual == timedelta(0):
 | 
				
			||||||
 | 
					            session.duration_manual = None
 | 
				
			||||||
 | 
					            session.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("tracker", "0005_auto_20230109_1843"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RunPython(
 | 
				
			||||||
 | 
					            set_duration_manual_none_to_zero,
 | 
				
			||||||
 | 
					            revert_set_duration_manual_none_to_zero,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -1,5 +1,11 @@
 | 
				
			|||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from datetime import timedelta
 | 
					from datetime import datetime, timedelta
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from zoneinfo import ZoneInfo
 | 
				
			||||||
 | 
					from common.util.time import format_duration
 | 
				
			||||||
 | 
					from django.db.models import Sum, F
 | 
				
			||||||
 | 
					from django.db.models import Manager
 | 
				
			||||||
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Game(models.Model):
 | 
					class Game(models.Model):
 | 
				
			||||||
@ -28,27 +34,58 @@ class Platform(models.Model):
 | 
				
			|||||||
        return self.name
 | 
					        return self.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SessionQuerySet(models.QuerySet):
 | 
				
			||||||
 | 
					    def total_duration(self):
 | 
				
			||||||
 | 
					        result = self.aggregate(
 | 
				
			||||||
 | 
					            duration=Sum(F("duration_calculated") + F("duration_manual"))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return format_duration(result["duration"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Session(models.Model):
 | 
					class Session(models.Model):
 | 
				
			||||||
    purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
 | 
					    purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
 | 
				
			||||||
    timestamp_start = models.DateTimeField()
 | 
					    timestamp_start = models.DateTimeField()
 | 
				
			||||||
    timestamp_end = models.DateTimeField(blank=True, null=True)
 | 
					    timestamp_end = models.DateTimeField(blank=True, null=True)
 | 
				
			||||||
    duration_manual = models.DurationField(blank=True, null=True)
 | 
					    duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
 | 
				
			||||||
    duration_calculated = models.DurationField(blank=True, null=True)
 | 
					    duration_calculated = models.DurationField(blank=True, null=True)
 | 
				
			||||||
    note = models.TextField(blank=True, null=True)
 | 
					    note = models.TextField(blank=True, null=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = SessionQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        mark = ", manual" if self.duration_manual != None else ""
 | 
					        mark = ", manual" if self.duration_manual != None else ""
 | 
				
			||||||
        return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.total_duration()}{mark})"
 | 
					        return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def calculated_duration(self):
 | 
					    def finish_now(self):
 | 
				
			||||||
        if self.timestamp_end == None or self.timestamp_start == None:
 | 
					        self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
 | 
				
			||||||
            return 0
 | 
					
 | 
				
			||||||
 | 
					    def start_now():
 | 
				
			||||||
 | 
					        self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def duration_seconds(self) -> timedelta:
 | 
				
			||||||
 | 
					        manual = timedelta(0)
 | 
				
			||||||
 | 
					        calculated = timedelta(0)
 | 
				
			||||||
 | 
					        if not self.duration_manual in (None, 0, timedelta(0)):
 | 
				
			||||||
 | 
					            manual = self.duration_manual
 | 
				
			||||||
 | 
					        if self.timestamp_end != None and self.timestamp_start != None:
 | 
				
			||||||
 | 
					            calculated = self.timestamp_end - self.timestamp_start
 | 
				
			||||||
 | 
					        return timedelta(seconds=(manual + calculated).total_seconds())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def duration_formatted(self) -> str:
 | 
				
			||||||
 | 
					        result = format_duration(self.duration_seconds(), "%H:%m")
 | 
				
			||||||
 | 
					        return result
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def duration_sum(self) -> str:
 | 
				
			||||||
 | 
					        return Session.objects.all().total_duration()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def last(self) -> Manager[Any]:
 | 
				
			||||||
 | 
					        return Session.objects.all().order_by("timestamp_start")[:-1]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        if self.timestamp_start != None and self.timestamp_end != None:
 | 
				
			||||||
 | 
					            self.duration_calculated = self.timestamp_end - self.timestamp_start
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            return self.timestamp_end - self.timestamp_start
 | 
					            self.duration_calculated = timedelta(0)
 | 
				
			||||||
 | 
					        super(Session, self).save(*args, **kwargs)
 | 
				
			||||||
    def total_duration(self):
 | 
					 | 
				
			||||||
        return (
 | 
					 | 
				
			||||||
            self.calculated_duration()
 | 
					 | 
				
			||||||
            if self.duration_manual == None
 | 
					 | 
				
			||||||
            else self.duration_manual + self.calculated_duration()
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -683,58 +683,102 @@ select {
 | 
				
			|||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.\!container {
 | 
				
			||||||
 | 
					  width: 100% !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media (min-width: 640px) {
 | 
					@media (min-width: 640px) {
 | 
				
			||||||
  .container {
 | 
					  .container {
 | 
				
			||||||
    max-width: 640px;
 | 
					    max-width: 640px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .\!container {
 | 
				
			||||||
 | 
					    max-width: 640px !important;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media (min-width: 768px) {
 | 
					@media (min-width: 768px) {
 | 
				
			||||||
  .container {
 | 
					  .container {
 | 
				
			||||||
    max-width: 768px;
 | 
					    max-width: 768px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .\!container {
 | 
				
			||||||
 | 
					    max-width: 768px !important;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media (min-width: 1024px) {
 | 
					@media (min-width: 1024px) {
 | 
				
			||||||
  .container {
 | 
					  .container {
 | 
				
			||||||
    max-width: 1024px;
 | 
					    max-width: 1024px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .\!container {
 | 
				
			||||||
 | 
					    max-width: 1024px !important;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media (min-width: 1280px) {
 | 
					@media (min-width: 1280px) {
 | 
				
			||||||
  .container {
 | 
					  .container {
 | 
				
			||||||
    max-width: 1280px;
 | 
					    max-width: 1280px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .\!container {
 | 
				
			||||||
 | 
					    max-width: 1280px !important;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media (min-width: 1536px) {
 | 
					@media (min-width: 1536px) {
 | 
				
			||||||
  .container {
 | 
					  .container {
 | 
				
			||||||
    max-width: 1536px;
 | 
					    max-width: 1536px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .\!container {
 | 
				
			||||||
 | 
					    max-width: 1536px !important;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.visible {
 | 
				
			||||||
 | 
					  visibility: visible;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.collapse {
 | 
				
			||||||
 | 
					  visibility: collapse;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.static {
 | 
					.static {
 | 
				
			||||||
  position: static;
 | 
					  position: static;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.fixed {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.\!fixed {
 | 
				
			||||||
 | 
					  position: fixed !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.absolute {
 | 
					.absolute {
 | 
				
			||||||
  position: absolute;
 | 
					  position: absolute;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.left-0 {
 | 
					.relative {
 | 
				
			||||||
  left: 0px;
 | 
					  position: relative;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.bottom-0 {
 | 
					.sticky {
 | 
				
			||||||
  bottom: 0px;
 | 
					  position: sticky;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.left-1 {
 | 
					.\!sticky {
 | 
				
			||||||
  left: 0.25rem;
 | 
					  position: sticky !important;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.bottom-1 {
 | 
					.left-2 {
 | 
				
			||||||
  bottom: 0.25rem;
 | 
					  left: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.bottom-2 {
 | 
				
			||||||
 | 
					  bottom: 0.5rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.mx-auto {
 | 
					.mx-auto {
 | 
				
			||||||
@ -750,32 +794,84 @@ select {
 | 
				
			|||||||
  margin-top: 1rem;
 | 
					  margin-top: 1rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.ml-1 {
 | 
				
			||||||
 | 
					  margin-left: 0.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.block {
 | 
					.block {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.inline-block {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.inline {
 | 
				
			||||||
 | 
					  display: inline;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.flex {
 | 
					.flex {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.grid {
 | 
					.table {
 | 
				
			||||||
  display: grid;
 | 
					  display: table;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.h-screen {
 | 
					.table-caption {
 | 
				
			||||||
  height: 100vh;
 | 
					  display: table-caption;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.table-cell {
 | 
				
			||||||
 | 
					  display: table-cell;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.contents {
 | 
				
			||||||
 | 
					  display: contents;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.hidden {
 | 
				
			||||||
 | 
					  display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.\!hidden {
 | 
				
			||||||
 | 
					  display: none !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.h-5 {
 | 
				
			||||||
 | 
					  height: 1.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.h-4 {
 | 
				
			||||||
 | 
					  height: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.min-h-screen {
 | 
				
			||||||
 | 
					  min-height: 100vh;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.w-full {
 | 
					.w-full {
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.w-5 {
 | 
				
			||||||
 | 
					  width: 1.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.w-7 {
 | 
				
			||||||
 | 
					  width: 1.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.w-4 {
 | 
				
			||||||
 | 
					  width: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.max-w-screen-lg {
 | 
					.max-w-screen-lg {
 | 
				
			||||||
  max-width: 1024px;
 | 
					  max-width: 1024px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.grid-cols-4 {
 | 
					.resize {
 | 
				
			||||||
  grid-template-columns: repeat(4, minmax(0, 1fr));
 | 
					  resize: both;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.flex-col {
 | 
					.flex-col {
 | 
				
			||||||
@ -790,6 +886,10 @@ select {
 | 
				
			|||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.justify-end {
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.justify-center {
 | 
					.justify-center {
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -806,6 +906,12 @@ select {
 | 
				
			|||||||
  align-self: center;
 | 
					  align-self: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.truncate {
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.whitespace-nowrap {
 | 
					.whitespace-nowrap {
 | 
				
			||||||
  white-space: nowrap;
 | 
					  white-space: nowrap;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -814,10 +920,18 @@ select {
 | 
				
			|||||||
  border-radius: 0.25rem;
 | 
					  border-radius: 0.25rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.rounded-lg {
 | 
				
			||||||
 | 
					  border-radius: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.rounded-xl {
 | 
					.rounded-xl {
 | 
				
			||||||
  border-radius: 0.75rem;
 | 
					  border-radius: 0.75rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.border {
 | 
				
			||||||
 | 
					  border-width: 1px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.border-gray-200 {
 | 
					.border-gray-200 {
 | 
				
			||||||
  --tw-border-opacity: 1;
 | 
					  --tw-border-opacity: 1;
 | 
				
			||||||
  border-color: rgb(229 231 235 / var(--tw-border-opacity));
 | 
					  border-color: rgb(229 231 235 / var(--tw-border-opacity));
 | 
				
			||||||
@ -828,6 +942,21 @@ select {
 | 
				
			|||||||
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
 | 
					  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.bg-green-600 {
 | 
				
			||||||
 | 
					  --tw-bg-opacity: 1;
 | 
				
			||||||
 | 
					  background-color: rgb(22 163 74 / var(--tw-bg-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.bg-blue-600 {
 | 
				
			||||||
 | 
					  --tw-bg-opacity: 1;
 | 
				
			||||||
 | 
					  background-color: rgb(37 99 235 / var(--tw-bg-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.bg-red-600 {
 | 
				
			||||||
 | 
					  --tw-bg-opacity: 1;
 | 
				
			||||||
 | 
					  background-color: rgb(220 38 38 / var(--tw-bg-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.p-4 {
 | 
					.p-4 {
 | 
				
			||||||
  padding: 1rem;
 | 
					  padding: 1rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -841,6 +970,16 @@ select {
 | 
				
			|||||||
  padding-bottom: 0.5rem;
 | 
					  padding-bottom: 0.5rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.py-1 {
 | 
				
			||||||
 | 
					  padding-top: 0.25rem;
 | 
				
			||||||
 | 
					  padding-bottom: 0.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.px-2 {
 | 
				
			||||||
 | 
					  padding-left: 0.5rem;
 | 
				
			||||||
 | 
					  padding-right: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.pl-3 {
 | 
					.pl-3 {
 | 
				
			||||||
  padding-left: 0.75rem;
 | 
					  padding-left: 0.75rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -853,6 +992,10 @@ select {
 | 
				
			|||||||
  text-align: center;
 | 
					  text-align: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.text-right {
 | 
				
			||||||
 | 
					  text-align: right;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.text-4xl {
 | 
					.text-4xl {
 | 
				
			||||||
  font-size: 2.25rem;
 | 
					  font-size: 2.25rem;
 | 
				
			||||||
  line-height: 2.5rem;
 | 
					  line-height: 2.5rem;
 | 
				
			||||||
@ -863,72 +1006,107 @@ select {
 | 
				
			|||||||
  line-height: 1.75rem;
 | 
					  line-height: 1.75rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.text-lg {
 | 
					 | 
				
			||||||
  font-size: 1.125rem;
 | 
					 | 
				
			||||||
  line-height: 1.75rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.text-sm {
 | 
					 | 
				
			||||||
  font-size: 0.875rem;
 | 
					 | 
				
			||||||
  line-height: 1.25rem;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.text-xs {
 | 
					.text-xs {
 | 
				
			||||||
  font-size: 0.75rem;
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
  line-height: 1rem;
 | 
					  line-height: 1rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.text-base {
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  line-height: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.text-lg {
 | 
				
			||||||
 | 
					  font-size: 1.125rem;
 | 
				
			||||||
 | 
					  line-height: 1.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.font-semibold {
 | 
					.font-semibold {
 | 
				
			||||||
  font-weight: 600;
 | 
					  font-weight: 600;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.uppercase {
 | 
				
			||||||
 | 
					  text-transform: uppercase;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.lowercase {
 | 
				
			||||||
 | 
					  text-transform: lowercase;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.text-white {
 | 
					.text-white {
 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					  --tw-text-opacity: 1;
 | 
				
			||||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
					  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.text-slate-800 {
 | 
					 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					 | 
				
			||||||
  color: rgb(30 41 59 / var(--tw-text-opacity));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.text-slate-700 {
 | 
					 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					 | 
				
			||||||
  color: rgb(51 65 85 / var(--tw-text-opacity));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.text-slate-600 {
 | 
					 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					 | 
				
			||||||
  color: rgb(71 85 105 / var(--tw-text-opacity));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.text-slate-500 {
 | 
					 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					 | 
				
			||||||
  color: rgb(100 116 139 / var(--tw-text-opacity));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.text-slate-400 {
 | 
					 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					 | 
				
			||||||
  color: rgb(148 163 184 / var(--tw-text-opacity));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.text-slate-300 {
 | 
					.text-slate-300 {
 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					  --tw-text-opacity: 1;
 | 
				
			||||||
  color: rgb(203 213 225 / var(--tw-text-opacity));
 | 
					  color: rgb(203 213 225 / var(--tw-text-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.text-red-400 {
 | 
				
			||||||
 | 
					  --tw-text-opacity: 1;
 | 
				
			||||||
 | 
					  color: rgb(248 113 113 / var(--tw-text-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.shadow-md {
 | 
				
			||||||
 | 
					  --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
 | 
				
			||||||
 | 
					  --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
 | 
				
			||||||
 | 
					  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.shadow {
 | 
					.shadow {
 | 
				
			||||||
  --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
 | 
					  --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
 | 
				
			||||||
  --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
 | 
					  --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
 | 
				
			||||||
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
					  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.blur {
 | 
				
			||||||
 | 
					  --tw-blur: blur(8px);
 | 
				
			||||||
 | 
					  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.invert {
 | 
				
			||||||
 | 
					  --tw-invert: invert(100%);
 | 
				
			||||||
 | 
					  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.\!invert {
 | 
				
			||||||
 | 
					  --tw-invert: invert(100%) !important;
 | 
				
			||||||
 | 
					  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.filter {
 | 
				
			||||||
 | 
					  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.\!filter {
 | 
				
			||||||
 | 
					  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.transition {
 | 
				
			||||||
 | 
					  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
 | 
				
			||||||
 | 
					  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
 | 
				
			||||||
 | 
					  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
 | 
				
			||||||
 | 
					  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
 | 
				
			||||||
 | 
					  transition-duration: 150ms;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.duration-200 {
 | 
				
			||||||
 | 
					  transition-duration: 200ms;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.ease-in {
 | 
				
			||||||
 | 
					  transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.dark form label {
 | 
					.dark form label {
 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					  --tw-text-opacity: 1;
 | 
				
			||||||
  color: rgb(148 163 184 / var(--tw-text-opacity));
 | 
					  color: rgb(148 163 184 / var(--tw-text-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.dark form input,.dark select,.dark textarea {
 | 
					.dark form input,.dark 
 | 
				
			||||||
 | 
					select,.dark 
 | 
				
			||||||
 | 
					textarea {
 | 
				
			||||||
  border-width: 1px;
 | 
					  border-width: 1px;
 | 
				
			||||||
  --tw-border-opacity: 1;
 | 
					  --tw-border-opacity: 1;
 | 
				
			||||||
  border-color: rgb(15 23 42 / var(--tw-border-opacity));
 | 
					  border-color: rgb(15 23 42 / var(--tw-border-opacity));
 | 
				
			||||||
@ -938,16 +1116,69 @@ select {
 | 
				
			|||||||
  color: rgb(241 245 249 / var(--tw-text-opacity));
 | 
					  color: rgb(241 245 249 / var(--tw-text-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
form input[type=submit] {
 | 
					#session-table {
 | 
				
			||||||
 | 
					  display: grid;
 | 
				
			||||||
 | 
					  grid-template-columns: repeat(3, 2fr) 0.5fr 1fr;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#button-container button {
 | 
				
			||||||
 | 
					  margin-left: 0.25rem;
 | 
				
			||||||
 | 
					  margin-right: 0.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.hover\:bg-green-700:hover {
 | 
				
			||||||
  --tw-bg-opacity: 1;
 | 
					  --tw-bg-opacity: 1;
 | 
				
			||||||
  background-color: rgb(88 28 135 / var(--tw-bg-opacity));
 | 
					  background-color: rgb(21 128 61 / var(--tw-bg-opacity));
 | 
				
			||||||
  padding: 0.5rem;
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.hover\:bg-blue-700:hover {
 | 
				
			||||||
 | 
					  --tw-bg-opacity: 1;
 | 
				
			||||||
 | 
					  background-color: rgb(29 78 216 / var(--tw-bg-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.hover\:bg-red-700:hover {
 | 
				
			||||||
 | 
					  --tw-bg-opacity: 1;
 | 
				
			||||||
 | 
					  background-color: rgb(185 28 28 / var(--tw-bg-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.hover\:underline:hover {
 | 
					.hover\:underline:hover {
 | 
				
			||||||
  text-decoration-line: underline;
 | 
					  text-decoration-line: underline;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focus\:outline-none:focus {
 | 
				
			||||||
 | 
					  outline: 2px solid transparent;
 | 
				
			||||||
 | 
					  outline-offset: 2px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focus\:ring-2:focus {
 | 
				
			||||||
 | 
					  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
 | 
				
			||||||
 | 
					  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
 | 
				
			||||||
 | 
					  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focus\:ring-green-500:focus {
 | 
				
			||||||
 | 
					  --tw-ring-opacity: 1;
 | 
				
			||||||
 | 
					  --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focus\:ring-blue-500:focus {
 | 
				
			||||||
 | 
					  --tw-ring-opacity: 1;
 | 
				
			||||||
 | 
					  --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focus\:ring-red-500:focus {
 | 
				
			||||||
 | 
					  --tw-ring-opacity: 1;
 | 
				
			||||||
 | 
					  --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focus\:ring-offset-2:focus {
 | 
				
			||||||
 | 
					  --tw-ring-offset-width: 2px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.focus\:ring-offset-blue-200:focus {
 | 
				
			||||||
 | 
					  --tw-ring-offset-color: #bfdbfe;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.dark .dark\:border-white {
 | 
					.dark .dark\:border-white {
 | 
				
			||||||
  --tw-border-opacity: 1;
 | 
					  --tw-border-opacity: 1;
 | 
				
			||||||
  border-color: rgb(255 255 255 / var(--tw-border-opacity));
 | 
					  border-color: rgb(255 255 255 / var(--tw-border-opacity));
 | 
				
			||||||
@ -968,16 +1199,16 @@ form input[type=submit] {
 | 
				
			|||||||
  background-color: rgb(51 65 85 / var(--tw-bg-opacity));
 | 
					  background-color: rgb(51 65 85 / var(--tw-bg-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.dark .dark\:bg-slate-400 {
 | 
					 | 
				
			||||||
  --tw-bg-opacity: 1;
 | 
					 | 
				
			||||||
  background-color: rgb(148 163 184 / var(--tw-bg-opacity));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.dark .dark\:text-white {
 | 
					.dark .dark\:text-white {
 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					  --tw-text-opacity: 1;
 | 
				
			||||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
					  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dark .dark\:text-slate-600 {
 | 
				
			||||||
 | 
					  --tw-text-opacity: 1;
 | 
				
			||||||
 | 
					  color: rgb(71 85 105 / var(--tw-text-opacity));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.dark .dark\:text-slate-400 {
 | 
					.dark .dark\:text-slate-400 {
 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					  --tw-text-opacity: 1;
 | 
				
			||||||
  color: rgb(148 163 184 / var(--tw-text-opacity));
 | 
					  color: rgb(148 163 184 / var(--tw-text-opacity));
 | 
				
			||||||
@ -988,11 +1219,6 @@ form input[type=submit] {
 | 
				
			|||||||
  color: rgb(203 213 225 / var(--tw-text-opacity));
 | 
					  color: rgb(203 213 225 / var(--tw-text-opacity));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.dark .dark\:text-slate-600 {
 | 
					 | 
				
			||||||
  --tw-text-opacity: 1;
 | 
					 | 
				
			||||||
  color: rgb(71 85 105 / var(--tw-text-opacity));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@media (min-width: 768px) {
 | 
					@media (min-width: 768px) {
 | 
				
			||||||
  .md\:block {
 | 
					  .md\:block {
 | 
				
			||||||
    display: block;
 | 
					    display: block;
 | 
				
			||||||
@ -1005,4 +1231,4 @@ form input[type=submit] {
 | 
				
			|||||||
  .md\:flex-row {
 | 
					  .md\:flex-row {
 | 
				
			||||||
    flex-direction: row;
 | 
					    flex-direction: row;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,13 @@
 | 
				
			|||||||
{% extends 'base.html' %}
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block title %}{{ title }}{% endblock title %}
 | 
					{% block title %}{{ title }}{% endblock title %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
<form method="POST" enctype="multipart/form-data" class="mx-auto">
 | 
					    <form method="post" enctype="multipart/form-data" class="mx-auto">
 | 
				
			||||||
    {% csrf_token %}
 | 
					        {% csrf_token %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {{ form.as_p }}
 | 
					        {{ form.as_p }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <input type="submit" value="Submit">
 | 
					        <input type="submit" value="Submit"/>
 | 
				
			||||||
</form>
 | 
					    </form>
 | 
				
			||||||
{% endblock content %}
 | 
					{% endblock content %}
 | 
				
			||||||
@ -1,13 +0,0 @@
 | 
				
			|||||||
{% extends 'base.html' %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block title %}Add New Purchase{% endblock title %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block content %}
 | 
					 | 
				
			||||||
<form method="POST" enctype="multipart/form-data" class="mx-auto">
 | 
					 | 
				
			||||||
    {% csrf_token %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    {{ form.as_p }}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    <input type="submit" value="Submit">
 | 
					 | 
				
			||||||
</form>
 | 
					 | 
				
			||||||
{% endblock content %}
 | 
					 | 
				
			||||||
@ -1,13 +0,0 @@
 | 
				
			|||||||
{% extends 'base.html' %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block title %}Add New Session{% endblock title %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block content %}
 | 
					 | 
				
			||||||
<form method="POST" enctype="multipart/form-data" class="mx-auto">
 | 
					 | 
				
			||||||
    {% csrf_token %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    {{ form.as_p }}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    <input type="submit" value="Submit">
 | 
					 | 
				
			||||||
</form>
 | 
					 | 
				
			||||||
{% endblock content %}
 | 
					 | 
				
			||||||
@ -1,39 +1,48 @@
 | 
				
			|||||||
<!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="viewport" content="width=device-width, initial-scale=1">
 | 
					        <meta name="description" content="Self-hosted time-tracker."/>
 | 
				
			||||||
    <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
 | 
					        <meta name="keywords" content="time, tracking, video games, self-hosted"/>
 | 
				
			||||||
    <link rel="stylesheet" href="https://rsms.me/inter/inter.css">
 | 
					        <meta name="viewport" content="width=device-width, initial-scale=1"/>
 | 
				
			||||||
    <link rel="stylesheet" href="{% static 'base.css' %}" />
 | 
					        <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
 | 
				
			||||||
</head>
 | 
					        <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
 | 
				
			||||||
 | 
					        <link rel="stylesheet" href="{% static 'base.css' %}" />
 | 
				
			||||||
 | 
					    </head>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<body class="dark">
 | 
					    <body class="dark">
 | 
				
			||||||
    <div class="dark:bg-gray-800 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="#" class="flex items-center">
 | 
					                    <a href="{% url 'index' %}" class="flex items-center">
 | 
				
			||||||
                    <span class="text-4xl">⌚</span>
 | 
					                        <span class="text-4xl">⌚</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><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
 | 
					                            <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
 | 
				
			||||||
                        <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
 | 
					                            <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
 | 
				
			||||||
                        <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
 | 
					                            {% if game_available and platform_available %}
 | 
				
			||||||
                        <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
 | 
					                                <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
 | 
				
			||||||
                    </ul>
 | 
					                            {% endif %}
 | 
				
			||||||
 | 
					                            {% if purchase_available %}
 | 
				
			||||||
 | 
					                                <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
 | 
				
			||||||
 | 
					                            {% endif %}
 | 
				
			||||||
 | 
					                            {% if session_count > 0 %}
 | 
				
			||||||
 | 
					                                <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
 | 
				
			||||||
 | 
					                            {% endif %}
 | 
				
			||||||
 | 
					                        </ul>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </nav>
 | 
				
			||||||
        </nav>
 | 
					            {% block content %}No content here.{% endblock content %}
 | 
				
			||||||
        {% block content %}No content here.{% endblock %}
 | 
					        </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 id="version-info" class="absolute left-1 bottom-1 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
 | 
					    </body>
 | 
				
			||||||
</body>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										17
									
								
								src/web/tracker/templates/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/web/tracker/templates/index.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block title %}{{ title }}{% endblock title %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					    <div class="text-slate-300 mx-auto max-w-screen-lg text-center">
 | 
				
			||||||
 | 
					        {% if session_count > 0 %}
 | 
				
			||||||
 | 
					            You have played a total of {{ session_count }} sessions for a total of {{ total_duration }}.
 | 
				
			||||||
 | 
					        {% elif not game_available or not platform_available %}
 | 
				
			||||||
 | 
					            There are no games in the database. Start by clicking "New Game" and "New Platform".
 | 
				
			||||||
 | 
					        {% elif not purchase_available %}
 | 
				
			||||||
 | 
					            There are no owned games. Click "New Purchase" at the top.
 | 
				
			||||||
 | 
					        {% else %}
 | 
				
			||||||
 | 
					            You haven't played any games yet. Click "New Session" to add one now.
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					{% endblock content %}
 | 
				
			||||||
@ -3,22 +3,64 @@
 | 
				
			|||||||
{% block title %}Sessions{% endblock title %}
 | 
					{% block title %}Sessions{% endblock title %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
{% if purchase %}
 | 
					        <div class="text-center text-xl mb-4 dark:text-slate-400">
 | 
				
			||||||
<div class="text-center text-xl mb-4 dark:text-slate-400">
 | 
					            <a href="{% url 'start_session' dataset.last.purchase.id %}">
 | 
				
			||||||
    <h1>Listing sessions only for purchase "{{ purchase }}"</h1>
 | 
					                <button type="button" title="Track last tracked" class="py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ">
 | 
				
			||||||
    <a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a>
 | 
					                    New session of {{ dataset.last.purchase }}
 | 
				
			||||||
</div>
 | 
					                </button>
 | 
				
			||||||
{% endif %}
 | 
					            </a>
 | 
				
			||||||
<div class="grid grid-cols-4 gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
 | 
					            {% if purchase %}
 | 
				
			||||||
    <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
 | 
					            <h1>Listing sessions only for purchase "{{ purchase }}"</h1>
 | 
				
			||||||
    <div class="dark:border-white dark:text-slate-300 text-lg">Start</div>
 | 
					            <h2>Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</h2>
 | 
				
			||||||
    <div class="dark:border-white dark:text-slate-300 text-lg">End</div>
 | 
					            <a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a>
 | 
				
			||||||
    <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
 | 
					            {% endif %}
 | 
				
			||||||
    {% for data in dataset %}
 | 
					        </div>
 | 
				
			||||||
    <div class=""><a class="dark:text-white hover:underline" href="{% url 'list_sessions' data.purchase.id %}">{{ data.purchase }}</a></div>
 | 
					    <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
 | 
				
			||||||
    <div class="dark:text-slate-400">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
 | 
					        <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
 | 
				
			||||||
    <div class="dark:text-slate-400">{{ data.timestamp_end | date:"d/m/Y H:i" }}</div>
 | 
					        <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>
 | 
				
			||||||
    <div class="dark:text-slate-400">{{ data.time_delta }}</div>
 | 
					        <div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div>
 | 
				
			||||||
    {% endfor %}
 | 
					        <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
 | 
				
			||||||
</div>
 | 
					        <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
 | 
				
			||||||
 | 
					        {% for data in dataset %}
 | 
				
			||||||
 | 
					            <div><a class="dark:text-white hover:underline" href="{% url 'list_sessions' data.purchase.id %}">{{ data.purchase }}</a></div>
 | 
				
			||||||
 | 
					            <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
 | 
				
			||||||
 | 
					            <div class="dark:text-slate-400 text-center">
 | 
				
			||||||
 | 
					                {% if data.unfinished %}
 | 
				
			||||||
 | 
					                    <span class="text-red-400">Not finished yet.</span>
 | 
				
			||||||
 | 
					                {% elif data.duration_manual %}
 | 
				
			||||||
 | 
					                    --
 | 
				
			||||||
 | 
					                {% else %}
 | 
				
			||||||
 | 
					                    {{ data.timestamp_end | date:"d/m/Y H:i" }}
 | 
				
			||||||
 | 
					                {% endif %}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="dark:text-slate-400 flex">{{ data.duration_formatted }}{% if data.duration_manual %} <svg title="Added manually" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 ml-1 self-center">
 | 
				
			||||||
 | 
					                <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
 | 
				
			||||||
 | 
					              </svg>
 | 
				
			||||||
 | 
					              {% endif %}</div>
 | 
				
			||||||
 | 
					            <div id="button-container" class="flex justify-end">
 | 
				
			||||||
 | 
					                {% if data.unfinished %}
 | 
				
			||||||
 | 
					                    <a href="{% url 'update_session' data.id %}">
 | 
				
			||||||
 | 
					                        <button type="button" title="Set to finished" class="py-1 px-2 flex justify-center items-center  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 w-7 h-4 rounded-lg ">
 | 
				
			||||||
 | 
					                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
 | 
				
			||||||
 | 
					                                <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
 | 
				
			||||||
 | 
					                            </svg>
 | 
				
			||||||
 | 
					                        </button>
 | 
				
			||||||
 | 
					                    </a>
 | 
				
			||||||
 | 
					                {% endif %}
 | 
				
			||||||
 | 
					                <button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center  bg-blue-600 hover:bg-blue-700 focus:ring-blue-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 w-7 h-4 rounded-lg ">
 | 
				
			||||||
 | 
					                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
 | 
				
			||||||
 | 
					                        <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 href="{% url 'delete_session' data.id %}">
 | 
				
			||||||
 | 
					                    <button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center  bg-red-600 hover:bg-red-700 focus:ring-red-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 w-7 h-4 rounded-lg ">
 | 
				
			||||||
 | 
					                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
 | 
				
			||||||
 | 
					                            <path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" />
 | 
				
			||||||
 | 
					                        </svg>
 | 
				
			||||||
 | 
					                    </button>
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        {% endfor %}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
{% endblock content %}
 | 
					{% endblock content %}
 | 
				
			||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
from django import template
 | 
					from django import template
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -9,10 +10,14 @@ register = template.Library()
 | 
				
			|||||||
def version_date():
 | 
					def version_date():
 | 
				
			||||||
    return time.strftime(
 | 
					    return time.strftime(
 | 
				
			||||||
        "%d-%b-%Y %H:%m",
 | 
					        "%d-%b-%Y %H:%m",
 | 
				
			||||||
        time.gmtime(os.path.getmtime(os.path.abspath(os.path.join(".git")))),
 | 
					        time.gmtime(
 | 
				
			||||||
 | 
					            os.path.getmtime(
 | 
				
			||||||
 | 
					                os.path.abspath(os.path.join(settings.BASE_DIR, "..", "..", ".git"))
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@register.simple_tag
 | 
					@register.simple_tag
 | 
				
			||||||
def version():
 | 
					def version():
 | 
				
			||||||
    return os.environ.get("VERSION_NUMBER", "UNKNOWN VERSION")
 | 
					    return os.environ.get("VERSION_NUMBER", "git-main")
 | 
				
			||||||
 | 
				
			|||||||
@ -3,8 +3,25 @@ from django.urls import path
 | 
				
			|||||||
from . import views
 | 
					from . import views
 | 
				
			||||||
 | 
					
 | 
				
			||||||
urlpatterns = [
 | 
					urlpatterns = [
 | 
				
			||||||
 | 
					    path("", views.index, name="index"),
 | 
				
			||||||
    path("add-game/", views.add_game, name="add_game"),
 | 
					    path("add-game/", views.add_game, name="add_game"),
 | 
				
			||||||
 | 
					    path("add-platform/", views.add_platform, name="add_platform"),
 | 
				
			||||||
    path("add-session/", views.add_session, name="add_session"),
 | 
					    path("add-session/", views.add_session, name="add_session"),
 | 
				
			||||||
 | 
					    path(
 | 
				
			||||||
 | 
					        "update-session/by-session/<int:session_id>",
 | 
				
			||||||
 | 
					        views.update_session,
 | 
				
			||||||
 | 
					        name="update_session",
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    path(
 | 
				
			||||||
 | 
					        "start-session/<int:purchase_id>",
 | 
				
			||||||
 | 
					        views.start_session,
 | 
				
			||||||
 | 
					        name="start_session",
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    path(
 | 
				
			||||||
 | 
					        "delete_session/by-id/<int:session_id>",
 | 
				
			||||||
 | 
					        views.delete_session,
 | 
				
			||||||
 | 
					        name="delete_session",
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    path("add-purchase/", views.add_purchase, name="add_purchase"),
 | 
					    path("add-purchase/", views.add_purchase, name="add_purchase"),
 | 
				
			||||||
    path("list-sessions/", views.list_sessions, name="list_sessions"),
 | 
					    path("list-sessions/", views.list_sessions, name="list_sessions"),
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
 | 
				
			|||||||
@ -1,22 +1,55 @@
 | 
				
			|||||||
from django.shortcuts import render
 | 
					from django.shortcuts import render, redirect
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from .models import Game, Platform, Purchase, Session
 | 
					from .models import Game, Platform, Purchase, Session
 | 
				
			||||||
from .forms import SessionForm, PurchaseForm, GameForm
 | 
					from .forms import SessionForm, PurchaseForm, GameForm, PlatformForm
 | 
				
			||||||
from datetime import datetime
 | 
					from datetime import datetime, timedelta
 | 
				
			||||||
from django.db.models import ExpressionWrapper, F, DurationField
 | 
					from zoneinfo import ZoneInfo
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from common.util.time import now as now_with_tz, format_duration
 | 
				
			||||||
 | 
					from django.db.models import Sum
 | 
				
			||||||
import logging
 | 
					import logging
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def model_counts(request):
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        "game_available": Game.objects.count() != 0,
 | 
				
			||||||
 | 
					        "platform_available": Platform.objects.count() != 0,
 | 
				
			||||||
 | 
					        "purchase_available": Purchase.objects.count() != 0,
 | 
				
			||||||
 | 
					        "session_count": Session.objects.count(),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def add_session(request):
 | 
					def add_session(request):
 | 
				
			||||||
    context = {}
 | 
					    context = {}
 | 
				
			||||||
    now = datetime.now()
 | 
					    now = now_with_tz()
 | 
				
			||||||
    initial = {"timestamp_start": now, "timestamp_end": now}
 | 
					    initial = {"timestamp_start": now}
 | 
				
			||||||
    form = SessionForm(request.POST or None, initial=initial)
 | 
					    form = SessionForm(request.POST or None, initial=initial)
 | 
				
			||||||
    if form.is_valid():
 | 
					    if form.is_valid():
 | 
				
			||||||
        form.save()
 | 
					        form.save()
 | 
				
			||||||
 | 
					        return redirect("list_sessions")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context["title"] = "Add New Session"
 | 
				
			||||||
    context["form"] = form
 | 
					    context["form"] = form
 | 
				
			||||||
    return render(request, "add_session.html", context)
 | 
					    return render(request, "add.html", context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def update_session(request, session_id=None):
 | 
				
			||||||
 | 
					    session = Session.objects.get(id=session_id)
 | 
				
			||||||
 | 
					    session.finish_now()
 | 
				
			||||||
 | 
					    session.save()
 | 
				
			||||||
 | 
					    return redirect("list_sessions")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def start_session(request, purchase_id=None):
 | 
				
			||||||
 | 
					    session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()})
 | 
				
			||||||
 | 
					    session.save()
 | 
				
			||||||
 | 
					    return redirect("list_sessions")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def delete_session(request, session_id=None):
 | 
				
			||||||
 | 
					    session = Session.objects.get(id=session_id)
 | 
				
			||||||
 | 
					    session.delete()
 | 
				
			||||||
 | 
					    return redirect("list_sessions")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def list_sessions(request, purchase_id=None):
 | 
					def list_sessions(request, purchase_id=None):
 | 
				
			||||||
@ -26,13 +59,14 @@ def list_sessions(request, purchase_id=None):
 | 
				
			|||||||
        dataset = Session.objects.filter(purchase=purchase_id)
 | 
					        dataset = Session.objects.filter(purchase=purchase_id)
 | 
				
			||||||
        context["purchase"] = Purchase.objects.get(id=purchase_id)
 | 
					        context["purchase"] = Purchase.objects.get(id=purchase_id)
 | 
				
			||||||
    else:
 | 
					    else:
 | 
				
			||||||
        dataset = Session.objects.all()
 | 
					        dataset = Session.objects.all().order_by("timestamp_start")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    dataset = dataset.annotate(
 | 
					    for session in dataset:
 | 
				
			||||||
        time_delta=ExpressionWrapper(
 | 
					        if session.timestamp_end == None and session.duration_manual.seconds == 0:
 | 
				
			||||||
            F("timestamp_end") - F("timestamp_start"), output_field=DurationField()
 | 
					            session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
 | 
				
			||||||
        )
 | 
					            session.unfinished = True
 | 
				
			||||||
    )
 | 
					
 | 
				
			||||||
 | 
					    context["total_duration"] = dataset.total_duration()
 | 
				
			||||||
    context["dataset"] = dataset
 | 
					    context["dataset"] = dataset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return render(request, "list_sessions.html", context)
 | 
					    return render(request, "list_sessions.html", context)
 | 
				
			||||||
@ -45,9 +79,11 @@ def add_purchase(request):
 | 
				
			|||||||
    form = PurchaseForm(request.POST or None, initial=initial)
 | 
					    form = PurchaseForm(request.POST or None, initial=initial)
 | 
				
			||||||
    if form.is_valid():
 | 
					    if form.is_valid():
 | 
				
			||||||
        form.save()
 | 
					        form.save()
 | 
				
			||||||
 | 
					        return redirect("index")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    context["form"] = form
 | 
					    context["form"] = form
 | 
				
			||||||
    return render(request, "add_purchase.html", context)
 | 
					    context["title"] = "Add New Purchase"
 | 
				
			||||||
 | 
					    return render(request, "add.html", context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def add_game(request):
 | 
					def add_game(request):
 | 
				
			||||||
@ -55,7 +91,27 @@ def add_game(request):
 | 
				
			|||||||
    form = GameForm(request.POST or None)
 | 
					    form = GameForm(request.POST or None)
 | 
				
			||||||
    if form.is_valid():
 | 
					    if form.is_valid():
 | 
				
			||||||
        form.save()
 | 
					        form.save()
 | 
				
			||||||
 | 
					        return redirect("index")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    context["form"] = form
 | 
					    context["form"] = form
 | 
				
			||||||
    context["title"] = "Add New Game"
 | 
					    context["title"] = "Add New Game"
 | 
				
			||||||
    return render(request, "add.html", context)
 | 
					    return render(request, "add.html", context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def add_platform(request):
 | 
				
			||||||
 | 
					    context = {}
 | 
				
			||||||
 | 
					    form = PlatformForm(request.POST or None)
 | 
				
			||||||
 | 
					    if form.is_valid():
 | 
				
			||||||
 | 
					        form.save()
 | 
				
			||||||
 | 
					        return redirect("index")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context["form"] = form
 | 
				
			||||||
 | 
					    context["title"] = "Add New Platform"
 | 
				
			||||||
 | 
					    return render(request, "add.html", context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def index(request):
 | 
				
			||||||
 | 
					    context = {}
 | 
				
			||||||
 | 
					    context["total_duration"] = Session().duration_sum
 | 
				
			||||||
 | 
					    context["title"] = "Index"
 | 
				
			||||||
 | 
					    return render(request, "index.html", context)
 | 
				
			||||||
 | 
				
			|||||||
@ -25,7 +25,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
 | 
				
			|||||||
SECRET_KEY = "django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@="
 | 
					SECRET_KEY = "django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@="
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# SECURITY WARNING: don't run with debug turned on in production!
 | 
					# SECURITY WARNING: don't run with debug turned on in production!
 | 
				
			||||||
DEBUG = True
 | 
					DEBUG = False if os.environ.get("PROD") else True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ALLOWED_HOSTS = ["*"]
 | 
					ALLOWED_HOSTS = ["*"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -34,7 +34,6 @@ ALLOWED_HOSTS = ["*"]
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
INSTALLED_APPS = [
 | 
					INSTALLED_APPS = [
 | 
				
			||||||
    "tracker.apps.TrackerConfig",
 | 
					    "tracker.apps.TrackerConfig",
 | 
				
			||||||
    "django.contrib.admin",
 | 
					 | 
				
			||||||
    "django.contrib.auth",
 | 
					    "django.contrib.auth",
 | 
				
			||||||
    "django.contrib.contenttypes",
 | 
					    "django.contrib.contenttypes",
 | 
				
			||||||
    "django.contrib.sessions",
 | 
					    "django.contrib.sessions",
 | 
				
			||||||
@ -42,6 +41,10 @@ INSTALLED_APPS = [
 | 
				
			|||||||
    "django.contrib.staticfiles",
 | 
					    "django.contrib.staticfiles",
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if DEBUG:
 | 
				
			||||||
 | 
					    INSTALLED_APPS.append("django_extensions")
 | 
				
			||||||
 | 
					    INSTALLED_APPS.append("django.contrib.admin")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
MIDDLEWARE = [
 | 
					MIDDLEWARE = [
 | 
				
			||||||
    "django.middleware.security.SecurityMiddleware",
 | 
					    "django.middleware.security.SecurityMiddleware",
 | 
				
			||||||
    "django.contrib.sessions.middleware.SessionMiddleware",
 | 
					    "django.contrib.sessions.middleware.SessionMiddleware",
 | 
				
			||||||
@ -65,6 +68,7 @@ TEMPLATES = [
 | 
				
			|||||||
                "django.template.context_processors.request",
 | 
					                "django.template.context_processors.request",
 | 
				
			||||||
                "django.contrib.auth.context_processors.auth",
 | 
					                "django.contrib.auth.context_processors.auth",
 | 
				
			||||||
                "django.contrib.messages.context_processors.messages",
 | 
					                "django.contrib.messages.context_processors.messages",
 | 
				
			||||||
 | 
					                "tracker.views.model_counts",
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
@ -108,7 +112,7 @@ AUTH_PASSWORD_VALIDATORS = [
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
LANGUAGE_CODE = "en-us"
 | 
					LANGUAGE_CODE = "en-us"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
TIME_ZONE = os.environ.get("TZ", "UTC")
 | 
					TIME_ZONE = "Europe/Prague" if DEBUG else os.environ.get("TZ", "UTC")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
USE_I18N = True
 | 
					USE_I18N = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -119,6 +123,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"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# 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
 | 
				
			||||||
@ -129,8 +134,19 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
 | 
				
			|||||||
LOGGING = {
 | 
					LOGGING = {
 | 
				
			||||||
    "version": 1,
 | 
					    "version": 1,
 | 
				
			||||||
    "disable_existing_loggers": False,
 | 
					    "disable_existing_loggers": False,
 | 
				
			||||||
    "handlers": {"console": {"class": "logging.StreamHandler"}},
 | 
					    "handlers": {
 | 
				
			||||||
    "root": {"handlers": ["console"], "level": "WARNING"},
 | 
					        "console": {"class": "logging.StreamHandler"},
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "loggers": {
 | 
				
			||||||
 | 
					        "django": {
 | 
				
			||||||
 | 
					            "handlers": ["console"],
 | 
				
			||||||
 | 
					            "level": "INFO",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CSRF_TRUSTED_ORIGINS = ["https://tracker.kucharczyk.xyz"]
 | 
					_csrf_trusted_origins = os.environ.get("CSRF_TRUSTED_ORIGINS")
 | 
				
			||||||
 | 
					if _csrf_trusted_origins:
 | 
				
			||||||
 | 
					    CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
 | 
				
			||||||
 | 
					else:
 | 
				
			||||||
 | 
					    CSRF_TRUSTED_ORIGINS = []
 | 
				
			||||||
 | 
				
			|||||||
@ -16,10 +16,13 @@ Including another URLconf
 | 
				
			|||||||
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 django.conf import settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
urlpatterns = [
 | 
					urlpatterns = [
 | 
				
			||||||
    path("admin/", admin.site.urls),
 | 
					 | 
				
			||||||
    path("", RedirectView.as_view(url="/tracker/list-sessions")),
 | 
					    path("", RedirectView.as_view(url="/tracker/list-sessions")),
 | 
				
			||||||
    path("tracker/", include("tracker.urls")),
 | 
					    path("tracker/", include("tracker.urls")),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if settings.DEBUG:
 | 
				
			||||||
 | 
					    urlpatterns.append(path("admin/", admin.site.urls))
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										92
									
								
								tests/test_time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								tests/test_time.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,92 @@
 | 
				
			|||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					from web.common.util.time import format_duration
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FormatDurationTest(unittest.TestCase):
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return super().setUp()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_only_days(self):
 | 
				
			||||||
 | 
					        delta = timedelta(days=3)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%d days")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "3 days")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_only_hours(self):
 | 
				
			||||||
 | 
					        delta = timedelta(hours=1)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%H hours")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "1 hours")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_overflow_hours(self):
 | 
				
			||||||
 | 
					        delta = timedelta(hours=25)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%H hours")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "25 hours")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_overflow_hours_into_days(self):
 | 
				
			||||||
 | 
					        delta = timedelta(hours=25)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%d days, %H hours")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "1 days, 1 hours")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_only_minutes(self):
 | 
				
			||||||
 | 
					        delta = timedelta(minutes=34)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%m minutes")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "34 minutes")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_only_overflow_minutes(self):
 | 
				
			||||||
 | 
					        delta = timedelta(minutes=61)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%m minutes")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "61 minutes")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_overflow_minutes_into_hours(self):
 | 
				
			||||||
 | 
					        delta = timedelta(minutes=61)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%H hours, %m minutes")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "1 hours, 1 minutes")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_only_overflow_seconds(self):
 | 
				
			||||||
 | 
					        delta = timedelta(seconds=61)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%s seconds")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "61 seconds")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_overflow_seconds_into_minutes(self):
 | 
				
			||||||
 | 
					        delta = timedelta(seconds=61)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%m minutes, %s seconds")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "1 minutes, 1 seconds")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_only_rawseconds(self):
 | 
				
			||||||
 | 
					        delta = timedelta(seconds=5690)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%r total seconds")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "5690 total seconds")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_empty(self):
 | 
				
			||||||
 | 
					        delta = timedelta()
 | 
				
			||||||
 | 
					        result = format_duration(delta, "")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_zero(self):
 | 
				
			||||||
 | 
					        delta = timedelta()
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%r seconds")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "0 seconds")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_all_at_once(self):
 | 
				
			||||||
 | 
					        delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
 | 
				
			||||||
 | 
					        result = format_duration(
 | 
				
			||||||
 | 
					            delta, "%d days, %H hours, %m minutes, %s seconds, %r total seconds"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            result, "50 days, 10 hours, 34 minutes, 24 seconds, 4358064 total seconds"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_negative(self):
 | 
				
			||||||
 | 
					        delta = timedelta(hours=-2)
 | 
				
			||||||
 | 
					        result = format_duration(delta, "%H hours")
 | 
				
			||||||
 | 
					        self.assertEqual(result, "0 hours")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_none(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            format_duration(None)
 | 
				
			||||||
 | 
					        except TypeError as exc:
 | 
				
			||||||
 | 
					            assert False, f"format_duration(None) raised an exception {exc}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_number(self):
 | 
				
			||||||
 | 
					        self.assertEqual(format_duration(3600, "%H hour"), "1 hour")
 | 
				
			||||||
		Reference in New Issue
	
	Block a user