59 Commits

Author SHA1 Message Date
f550978e4a Release version 0.1.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-05 23:31:06 +01:00
db5de81c09 Add make date 2023-01-05 23:30:43 +01:00
15ed6504b1 Order by timestamp_start by default 2023-01-05 23:30:13 +01:00
fd9bf8c026 Improve manual duration mark 2023-01-05 23:10:42 +01:00
5172c38c16 Refactor the calculated_sum and manual_sum 2023-01-05 23:03:46 +01:00
9c56ed4ce8 Make logging work 2023-01-05 22:14:51 +01:00
d00bb1cd06 Properly unset CSRF_TRUSTED_ORIGINS 2023-01-05 22:09:35 +01:00
bedfbb7f31 Remove cruft 2023-01-05 22:09:21 +01:00
f2b08cd1cd Changes after linting 2023-01-05 22:05:26 +01:00
5ad0e52787 Turn on type checking 2023-01-05 22:01:27 +01:00
f7ec07994f Improve duration handling in Session model 2023-01-05 22:01:15 +01:00
03e89a92c7 Fix another duration formatting error 2023-01-05 22:00:51 +01:00
76bf03b482 Improve session list 2023-01-05 22:00:08 +01:00
e6b5804e37 Update infra 2023-01-05 21:56:57 +01:00
2807c5e00e Add pre-commit hook to update version 2023-01-05 21:10:17 +01:00
8efce77062 Improve newcomer experience
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-05 17:15:14 +01:00
89be0c031b Fix errors with empty database 2023-01-05 17:14:34 +01:00
4e67735de8 Fix negative playtimes being considered positive 2023-01-05 17:13:45 +01:00
869e0e0fe0 Run all python Makefile commands via poetry 2023-01-05 17:12:57 +01:00
85f52fc735 Fix checking duration when database is empty
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-05 16:33:32 +01:00
34ce1e9b05 Set up tests, add tests for common.util.time, add %d
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-05 15:18:57 +01:00
67f5090bf8 Rely on poetry for make test
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-05 12:19:56 +01:00
51d5306f91 Add more cache dirs to .gitignore 2023-01-05 12:19:43 +01:00
66a49ff911 Rely on poetry's virtual env 2023-01-05 12:19:32 +01:00
3e32261d4a Set up test environment for testing
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-05 12:05:16 +01:00
9b07758198 Install poetry before testing
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-05 12:02:01 +01:00
c57f969a00 Add tests for common.util.time
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-05 11:52:50 +01:00
fd7fc7c710 Annotate common.util.time.now
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-05 11:24:31 +01:00
32f10e183e Display total hours played on homepage
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-05 11:24:07 +01:00
fdb9aa8e84 Add format_duration to common.util.time 2023-01-05 11:17:01 +01:00
4b45127335 Allow deleting sessions
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-04 20:28:07 +01:00
b8a15e43db Redirect after adding game/platform/purchase/session 2023-01-04 19:35:35 +01:00
a1309c3738 Fix display of duration_manual 2023-01-04 19:32:18 +01:00
12cc9025a0 Fix display of duration_calculated, display durations less than a minute 2023-01-04 19:26:01 +01:00
6fe960bc04 Make the "Finish now?" button on session list work 2023-01-04 19:19:49 +01:00
61d2e65d83 Remove cruft
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-04 17:46:06 +01:00
84dafe9223 Update generated CSS
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-04 17:29:19 +01:00
59cf620ff3 Set version in the footer to fixed, fix main container height 2023-01-04 17:28:51 +01:00
40810256aa Improve session listing 2023-01-04 17:27:54 +01:00
b3842504af Save calculated duration to database 2023-01-04 17:25:19 +01:00
bf61326c18 Make it possible to add a new platform 2023-01-04 17:24:54 +01:00
4c642d97cb Add homepage, link to it from the logo 2023-01-04 17:22:36 +01:00
d225856174 Set default version to "git-main" 2023-01-04 17:20:46 +01:00
5c50e059e6 Hide navigation bar items
If there are no games/purchases/sessions,
hide the related navbar items
2023-01-04 17:19:40 +01:00
84c92fe654 Fix version
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 22:33:03 +01:00
166dd716ed Remove non-working tag from CI file
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 22:06:43 +01:00
6102459637 Set VERSION_NUMBER in Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-03 22:04:46 +01:00
e4cd75d51f Use add.html for add_purchase 2023-01-03 22:04:36 +01:00
b1c8f58855 Add make shell 2023-01-03 22:04:22 +01:00
250f841e00 Try fixing CI
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-03 21:44:08 +01:00
89adf479f6 Include version in the footer
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-03 21:35:09 +01:00
5f9ca5781f Properly set TIME_ZONE 2023-01-03 20:52:23 +01:00
1a2f0b974d Set timezone from TZ env variable
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 20:41:26 +01:00
c6bb60bbbb Prevent error from empty timestamps
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 20:23:49 +01:00
126e758172 Add trusted domain for CSRF
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 20:11:59 +01:00
6e4db38ee4 Change session datetime display format
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 19:03:46 +01:00
aae05f23e7 Filtering sessions by purchase 2023-01-03 19:03:30 +01:00
cd35af471a Enable logging 2023-01-03 19:01:17 +01:00
d896a37779 Fix dark mode issues 2023-01-03 19:01:10 +01:00
27 changed files with 1226 additions and 129 deletions

View File

@ -1,14 +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
tags: latest tags:
- latest
trigger: trigger:
event: event:
- push - push

14
.githooks/pre-commit Executable file
View 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 "----------------"

2
.gitignore vendored
View File

@ -1,4 +1,6 @@
__pycache__ __pycache__
.mypy_cache
.pytest_cache
.venv .venv
node_modules node_modules
package-lock.json package-lock.json

8
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic"
}

22
CHANGELOG.md Normal file
View File

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

View File

@ -1,15 +1,26 @@
FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify
FROM python:3.10-slim-bullseye FROM python:3.10-slim-bullseye
ENV VIRTUAL_ENV=/opt/venv
RUN python3 -m venv pip $VIRTUAL_ENV ENV VERSION_NUMBER 0.1.0-48-gdb5de81
ENV PATH="$VIRTUAL_ENV/bin:$PATH" ENV PROD 1
RUN pip install --no-cache-dir poetry
RUN useradd --create-home --uid 1000 timetracker RUN useradd --create-home --uid 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" ]

View File

@ -1,8 +1,8 @@
.PHONY: createsuperuser .PHONY: createsuperuser shell
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 +16,37 @@ 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_plus
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
poetry.lock: pyproject.toml
poetry install
test: poetry.lock
poetry run pytest
sethookdir:
git config core.hooksPath .githooks
make date:
python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'

View File

@ -1,8 +1,8 @@
#!/bin/bash #!/bin/bash
# Apply database migrations # Apply database migrations
echo "Apply database migrations" echo "Apply database migrations"
python src/web/manage.py migrate poetry run python src/web/manage.py migrate
# Start server # Start server
echo "Starting server" echo "Starting server"
python src/web/manage.py runserver 0.0.0.0:8000 poetry run python src/web/manage.py runserver 0.0.0.0:8000

463
poetry.lock generated
View File

@ -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"
@ -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,209 @@ 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 = "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 +399,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 +439,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 +529,116 @@ 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 = "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 +663,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 +708,41 @@ files = [
{file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"}, {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"},
] ]
[[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 = "4e2d507e5549e97f894ebcaf0781c1fa54acf3444583510c36d3807e6c4761d9"

View File

@ -14,7 +14,17 @@ django = "^4.1.4"
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"
]

View File

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

View File

View File

@ -0,0 +1,44 @@
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 format_duration(
duration: timedelta, format_string: str = "%H hours %m minutes"
) -> str:
"""
Format timedelta into the specified format_string.
Valid format variables:
- %H hours
- %m minutes
- %s seconds
- %r total seconds
"""
minute_seconds = 60
hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds
if not isinstance(duration, timedelta):
duration = timedelta(seconds=duration)
seconds_total = int(duration.total_seconds())
# timestamps where end is before start
if seconds_total < 0:
seconds_total = 0
days, remainder = divmod(seconds_total, day_seconds)
hours, remainder = divmod(remainder, hour_seconds)
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

View File

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

View File

@ -1,5 +1,9 @@
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
class Game(models.Model): class Game(models.Model):
@ -38,14 +42,59 @@ class Session(models.Model):
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_any()}{mark})"
def calculated_duration(self): def finish_now(self):
return self.timestamp_end - self.timestamp_start self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def total_duration(self): def duration_seconds(self) -> timedelta:
if self.duration_manual == None:
if self.timestamp_end == None or self.timestamp_start == None:
return timedelta(0)
else:
value = self.timestamp_end - self.timestamp_start
else:
value = self.duration_manual
return timedelta(seconds=value.total_seconds())
def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%H:%m")
return result
def duration_any(self):
return ( return (
self.calculated_duration() self.duration_formatted()
if self.duration_manual == None if self.duration_manual == None
else self.duration_manual + self.calculated_duration() else self.duration_manual
) )
@staticmethod
def calculated_sum() -> timedelta:
calculated_sum_query = Session.objects.all().aggregate(
Sum("duration_calculated")
)
calculated_sum = (
timedelta(0)
if calculated_sum_query["duration_calculated__sum"] == None
else calculated_sum_query["duration_calculated__sum"]
)
return calculated_sum
@staticmethod
def manual_sum() -> timedelta:
manual_sum_query = Session.objects.all().aggregate(Sum("duration_manual"))
manual_sum = (
timedelta(0)
if manual_sum_query["duration_manual__sum"] == None
else manual_sum_query["duration_manual__sum"]
)
return manual_sum
@staticmethod
def total_sum() -> timedelta:
return Session.manual_sum() + Session.calculated_sum()
def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start
super(Session, self).save(*args, **kwargs)

View File

@ -717,6 +717,18 @@ select {
position: static; position: static;
} }
.fixed {
position: fixed;
}
.left-2 {
left: 0.5rem;
}
.bottom-2 {
bottom: 0.5rem;
}
.mx-auto { .mx-auto {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@ -730,6 +742,10 @@ select {
margin-top: 1rem; margin-top: 1rem;
} }
.ml-1 {
margin-left: 0.25rem;
}
.block { .block {
display: block; display: block;
} }
@ -738,20 +754,36 @@ select {
display: flex; display: flex;
} }
.grid { .h-5 {
display: grid; height: 1.25rem;
}
.h-4 {
height: 1rem;
}
.min-h-screen {
min-height: 100vh;
} }
.w-full { .w-full {
width: 100%; width: 100%;
} }
.max-w-screen-lg { .w-5 {
max-width: 1024px; width: 1.25rem;
} }
.grid-cols-4 { .w-7 {
grid-template-columns: repeat(4, minmax(0, 1fr)); width: 1.75rem;
}
.w-4 {
width: 1rem;
}
.max-w-screen-lg {
max-width: 1024px;
} }
.flex-col { .flex-col {
@ -766,6 +798,10 @@ select {
align-items: center; align-items: center;
} }
.justify-end {
justify-content: flex-end;
}
.justify-center { .justify-center {
justify-content: center; justify-content: center;
} }
@ -794,6 +830,10 @@ select {
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.rounded-lg {
border-radius: 0.5rem;
}
.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));
@ -804,6 +844,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;
} }
@ -817,6 +872,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;
} }
@ -825,6 +890,14 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.text-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;
@ -835,11 +908,21 @@ select {
line-height: 1.75rem; line-height: 1.75rem;
} }
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-lg { .text-lg {
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.75rem; line-height: 1.75rem;
} }
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.font-semibold { .font-semibold {
font-weight: 600; font-weight: 600;
} }
@ -849,21 +932,134 @@ select {
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
.text-slate-300 {
--tw-text-opacity: 1;
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 { .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);
} }
.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);
}
.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 {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.dark form input,.dark
select,.dark
textarea {
border-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(15 23 42 / var(--tw-border-opacity));
--tw-bg-opacity: 1;
background-color: rgb(100 116 139 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(241 245 249 / var(--tw-text-opacity));
}
#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;
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-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));
} }
.dark .dark\:bg-gray-800 {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
.dark .dark\:bg-gray-900 { .dark .dark\:bg-gray-900 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
@ -874,9 +1070,14 @@ select {
background-color: rgb(51 65 85 / var(--tw-bg-opacity)); background-color: rgb(51 65 85 / var(--tw-bg-opacity));
} }
.dark .dark\:text-slate-300 { .dark .dark\:text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(203 213 225 / 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 {
@ -884,6 +1085,11 @@ select {
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.dark .dark\:text-slate-300 {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity));
}
@media (min-width: 768px) { @media (min-width: 768px) {
.md\:block { .md\:block {
display: block; display: block;
@ -896,4 +1102,4 @@ select {
.md\:flex-row { .md\:flex-row {
flex-direction: row; flex-direction: row;
} }
} }

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -1,35 +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>{% block title %}No Title{% 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">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <div class="dark:bg-gray-800 min-h-screen">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<a href="#" class="flex items-center"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<span class="text-4xl"></span> <a href="{% url 'index' %}" class="flex items-center">
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="text-4xl"></span>
</a> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
<div class="w-full md:block md:w-auto"> </a>
<ul <div class="w-full md:block md:w-auto">
class="flex flex-col md:flex-row p-4 mt-4"> <ul
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> 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_purchase' %}">New Purchase</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_session' %}">New Session</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 'list_sessions' %}">All Sessions</a></li> {% if game_available and platform_available %}
</ul> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
</div> {% 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>
</nav>
{% block content %}No content here.{% endblock content %}
</div> </div>
</nav> {% load version %}
{% block content %}No content here.{% endblock %} <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</body> </body>
</html> </html>

View 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 %}

View File

@ -1,18 +1,60 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Tracker Entry List{% endblock title %} {% block title %}Sessions{% endblock title %}
{% block content %} {% block content %}
<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> <div class="text-center text-xl mb-4 dark:text-slate-400">
<div class="dark:border-white dark:text-slate-300 text-lg">Start</div> <h1>Listing sessions only for purchase "{{ purchase }}"</h1>
<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> </div>
{% for data in dataset %} {% endif %}
<div class="dark:text-slate-400">{{ data.purchase }}</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 }}</div> <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
<div class="dark:text-slate-400">{{ data.timestamp_end }}</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 %}

View File

@ -0,0 +1,18 @@
from django import template
import time
import os
register = template.Library()
@register.simple_tag
def version_date():
return time.strftime(
"%d-%b-%Y %H:%m",
time.gmtime(os.path.getmtime(os.path.abspath(os.path.join(".git")))),
)
@register.simple_tag
def version():
return os.environ.get("VERSION_NUMBER", "git-main")

View File

@ -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(
"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(
"list-sessions/by-purchase/<int:purchase_id>",
views.list_sessions,
name="list_sessions",
),
] ]

View File

@ -1,30 +1,65 @@
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
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 list_sessions(request): def update_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.finish_now()
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):
context = {} context = {}
dataset = Session.objects.annotate(
time_delta=ExpressionWrapper( if purchase_id != None:
F("timestamp_end") - F("timestamp_start"), output_field=DurationField() dataset = Session.objects.filter(purchase=purchase_id)
) context["purchase"] = Purchase.objects.get(id=purchase_id)
) else:
dataset = Session.objects.all().order_by("timestamp_start")
for session in dataset:
if session.timestamp_end == None and session.duration_manual == None:
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True
context["dataset"] = dataset context["dataset"] = dataset
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@ -37,9 +72,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):
@ -47,7 +84,33 @@ 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 = {}
if Session.objects.count() == 0:
duration: str = ""
else:
context["total_duration"] = format_duration(
Session.total_sum(),
"%H hours %m minutes",
)
context["title"] = "Index"
return render(request, "index.html", context)

View File

@ -11,6 +11,8 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
""" """
from pathlib import Path from pathlib import Path
import logging
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -23,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 = ["*"]
@ -40,6 +42,9 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
] ]
if DEBUG:
INSTALLED_APPS.append("django_extensions")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
@ -63,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",
], ],
}, },
}, },
@ -106,7 +112,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC" TIME_ZONE = "Europe/Prague" if DEBUG else os.environ.get("TZ", "UTC")
USE_I18N = True USE_I18N = True
@ -122,3 +128,23 @@ STATIC_URL = "static/"
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# https://docs.djangoproject.com/en/4.1/topics/logging/
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
},
},
}
CSRF_TRUSTED_ORIGINS = []
if os.environ.get("PROD"):
CSRF_TRUSTED_ORIGINS.append(os.environ.get("CSRF_TRUSTED_ORIGINS"))

58
tests/test_time.py Normal file
View File

@ -0,0 +1,58 @@
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_only_minutes(self):
delta = timedelta(minutes=34)
result = format_duration(delta, "%m minutes")
self.assertEqual(result, "34 minutes")
def test_only_overflow_seconds(self):
delta = timedelta(seconds=61)
result = format_duration(delta, "%s seconds")
self.assertEqual(result, "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")