2 Commits

Author SHA1 Message Date
e4075151f3 Update version, changelog 2023-01-08 15:30:28 +01:00
d3682368b4 Fix CSRF error
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-08 15:23:04 +01:00
127 changed files with 1171 additions and 6398 deletions

View File

@ -1,17 +1 @@
.git src/web/static/*
.githooks
.mypy_cache
.pytest_cache
.venv
.vscode
node_modules
static
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile

View File

@ -5,52 +5,21 @@ name: default
steps: steps:
- name: test - name: test
image: python:3.12 image: python:3.10
commands: commands:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
- poetry env info - poetry env info
- poetry run python manage.py migrate
- poetry run pytest - poetry run pytest
- name: build container
- name: build-prod
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: tags:
- latest - latest
- 1.1.0
depends_on:
- "test"
when:
branch:
- main
- name: build-non-prod
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when:
branch:
exclude:
- main
depends_on:
- "test"
- name: redeploy on portainer
image: plugins/webhook
settings:
urls:
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
depends_on:
- "build-prod"
trigger: trigger:
event: event:
- push - push
- cron - cron
exclude:
- pull_request

View File

@ -1,17 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2

0
.githooks/pre-commit.disabled → .githooks/pre-commit Normal file → Executable file
View File

View File

@ -1,36 +0,0 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 3.12
- run: |
python -m pip install poetry
poetry install
poetry env info
poetry run python manage.py migrate
poetry run pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

3
.gitignore vendored
View File

@ -5,5 +5,4 @@ __pycache__
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
/static/ src/web/static
dist/

View File

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

View File

@ -4,8 +4,5 @@
], ],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic", "python.analysis.typeCheckingMode": "basic"
"[python]": { }
"editor.defaultFormatter": "ms-python.black-formatter"
},
}

View File

@ -1,196 +1,3 @@
## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
* add purchase: only allow choosing purchases of selected edition
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00
## New
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
* Backlog Decrease (count)
* New workflow:
* Adding Game, Edition, Purchase, and Session in a row is now much faster
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
* ignore English articles when sorting names
* added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
* new game: name and sort name after typing
* new edition: name, sort name, and year when selecting game
* new purchase: platform when selecting edition
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00
### New
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
### Enhancements
* Add a button to start session from game overview
## 1.1.2 / 2023-10-13 16:30+02:00
### Enhancements
* Durations are formatted in a consisent manner across all pages
### Fixes
* Game Overview: display duration when >1 hour instead of displaying 0
## 1.1.1 / 2023-10-09 20:52+02:00
### New
* Add notes section to game overview
### Enhancements
* Make it possible to add any data on the game overview page
## 1.1.0 / 2023-10-09 00:01+02:00
### New
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
* Add helper buttons next to datime fields
* Add copy button on Add session page to copy times between fields
* Change fonts to IBM Plex
### Enhancements
* Improve form appearance
* Focus important fields on forms
* Use the same form when editing a session as when adding a session
* Change recent session view to current year instead of last 30 days
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
### Fixes
* Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58)
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
## 1.0.3 / 2023-02-20 17:16+01:00
* Add wikidata ID and year for editions
* Add icons for game, edition, purchase filters
* Allow filtering by game, edition, purchase from the session list
* Allow editing filtered entities from session list
## 1.0.2 / 2023-02-18 21:48+01:00
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
* Add support for purchase prices
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
## 1.0.1 / 2023-01-30 22:17+01:00
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
* Show markers on smaller graphs to make it clearer which dates the session belong to
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
## 1.0.0 / 2023-01-20 19:54+01:00
* Breaking
* Due to major re-arranging and re-naming of the folder structure, tables also had to be renamed.
* Fixed
* Sort form fields alphabetically (https://git.kucharczyk.xyz/lukas/timetracker/issues/39, https://git.kucharczyk.xyz/lukas/timetracker/issues/40)
* Start session button starts different game than it says (#44)
## 0.2.5 / 2023-01-18 17:01+01:00
* New
* When adding session, pre-select game with the last session
* Fixed
* Start session now button would take up 100% width, leading to accidental clicks (https://git.kucharczyk.xyz/lukas/timetracker/issues/37)
* Removed
* Session model property `last` is already implemented by Django method `last()`, thus it was removed (https://git.kucharczyk.xyz/lukas/timetracker/issues/38)
## 0.2.4 / 2023-01-16 19:39+01:00
* Fixed
* When filtering by game, the "Filtering by (...)" text would erroneously list an unrelated platform
* Playtime graph would display timeline backwards
* Playtime graph with many dates would overlap (https://git.kucharczyk.xyz/lukas/timetracker/issues/34)
* Manually added times (= without end timestamp) would make graphs look ugly and noisy (https://git.kucharczyk.xyz/lukas/timetracker/issues/35)
## 0.2.3 / 2023-01-15 23:13+01:00
* Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32)
* Order session by newest as preparation for https://git.kucharczyk.xyz/lukas/timetracker/issues/33
## 0.2.2 / 2023-01-15 17:59+01:00
* Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29)
* Fix error when showing session list with no sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/31)
## 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 ## 0.1.3 / 2023-01-08 15:23+01:00
* Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22) * Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22)
@ -220,4 +27,4 @@
* Make it possible to add a new platform * Make it possible to add a new platform
* Save calculated duration to database if both timestamps are set * Save calculated duration to database if both timestamps are set
* Improve session listing * Improve session listing
* Set version in the footer to fixed, fix main container height * Set version in the footer to fixed, fix main container height

View File

@ -5,10 +5,10 @@
:8000 { :8000 {
handle_path /static/* { handle_path /static/* {
root * /usr/share/caddy root * src/web/static/
file_server file_server
} }
handle { handle {
reverse_proxy backend:8001 reverse_proxy :8001
} }
} }

View File

@ -1,45 +1,31 @@
FROM python:3.12.0-slim-bullseye 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
ENV VERSION_NUMBER=1.5.1 \ FROM python:3.10.9-alpine
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_ROOT_USER_ACTION=ignore \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
RUN apt-get update && apt-get upgrade -y \ ENV VERSION_NUMBER 0.1.0-60-gd368236
&& apt-get install --no-install-recommends -y \ ENV PROD 1
RUN apk add \
bash \ bash \
vim \
curl \ curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \ caddy
&& poetry --version \ RUN adduser -D -u 1000 timetracker
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker \
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css /app/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
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install --without dev
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] ENTRYPOINT [ "/entrypoint.sh" ]

View File

@ -2,52 +2,49 @@ all: css migrate
initialize: npm css migrate sethookdir loadplatforms initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find src/web/tracker/templates -type f)
npm: npm:
npm install npm install
css: common/input.css css: src/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css
css-dev: css css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations poetry run python src/web/manage.py makemigrations
migrate: makemigrations migrate: makemigrations
poetry run python manage.py migrate poetry run python src/web/manage.py migrate
dev: migrate dev: migrate sethookdir
poetry run python manage.py runserver poetry run python src/web/manage.py runserver_plus
caddy: caddy:
caddy run --watch caddy run --watch
dev-prod: migrate collectstatic dev-prod: migrate collectstatic sethookdir
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
dumpgames: dumptracker:
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml poetry run python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml
loadplatforms: loadplatforms:
poetry run python manage.py loaddata platforms.yaml poetry run python src/web/manage.py loaddata platforms.yaml
loadall:
poetry run python manage.py loaddata data.yaml
loadsample: loadsample:
poetry run python manage.py loaddata sample.yaml poetry run python src/web/manage.py loaddata sample.yaml
createsuperuser: createsuperuser:
poetry run python manage.py createsuperuser poetry run python src/web/manage.py createsuperuser
shell: shell:
poetry run python manage.py shell poetry run python src/web/manage.py shell
collectstatic: collectstatic:
poetry run python manage.py collectstatic --clear --no-input poetry run python src/web/manage.py collectstatic
poetry.lock: pyproject.toml poetry.lock: pyproject.toml
poetry install poetry install
@ -55,10 +52,13 @@ poetry.lock: pyproject.toml
test: poetry.lock test: poetry.lock
poetry run pytest poetry run pytest
sethookdir:
git config core.hooksPath .githooks
date: date:
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
cleanstatic: cleanstatic:
rm -r static/* rm -r src/web/static/*
clean: cleanstatic clean: cleanstatic

View File

@ -1,30 +0,0 @@
import csv
from typing import TypeAlias
from games.models import Game
DataList: TypeAlias = list[dict[str, str]] | None
def read_csv(filename: str) -> DataList:
with open(filename, "r") as csvfile:
writer = csv.DictReader(csvfile)
return writer
def import_data(data: DataList):
matching_names = {}
for line in data:
name = line["name"]
if name not in matching_names:
# try exact match first
try:
game_id = Game.objects.get(name__iexact=name)
except:
pass
matching_names[name] = game_id
print(f"Exact matched {len(matching_names)} games.")
def import_from_file(filename: str):
import_data(read_csv(filename))

View File

@ -1,113 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
form label {
@apply dark:text-slate-400;
}
.responsive-table {
@apply dark:text-white mx-auto;
}
.responsive-table tr:nth-child(even) {
@apply bg-slate-800
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
@layer utilities {
.max-w-20char {
max-width: 20ch;
}
.max-w-35char {
max-width: 40ch;
}
.max-w-40char {
max-width: 40ch;
}
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
}
#button-container button {
@apply mx-1;
}
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}

View File

@ -1,72 +0,0 @@
import re
from datetime import timedelta
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"
Format specifiers can include width and precision options:
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
"""
minute_seconds = 60
hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds
safe_duration = _safe_timedelta(duration)
# we don't need float
seconds_total = int(safe_duration.total_seconds())
# timestamps where end is before start
if seconds_total < 0:
seconds_total = 0
days = hours = hours_float = minutes = seconds = 0
remainder = seconds = seconds_total
if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds)
if re.search(r"%\d*\.?\d*H", format_string):
hours_float, remainder = divmod(remainder, hour_seconds)
hours = float(hours_float) + remainder / hour_seconds
if re.search(r"%\d*\.?\d*m", format_string):
minutes, seconds = divmod(remainder, minute_seconds)
literals = {
"d": str(days),
"H": str(hours) if "m" not in format_string else str(hours_float),
"m": str(minutes),
"s": str(seconds),
"r": str(seconds_total),
}
formatted_string = format_string
for pattern, replacement in literals.items():
# Match format specifiers with optional width and precision
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
if match:
format_spec = match.group(1)
if "." in format_spec:
# Format the number as float if precision is specified
replacement = f"{float(replacement):{format_spec}f}"
else:
# Format the number as integer if no precision is specified
replacement = f"{int(float(replacement)):>{format_spec}}"
# Replace the format specifier with the formatted number
formatted_string = re.sub(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string

View File

@ -1,9 +0,0 @@
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0

View File

@ -1,17 +0,0 @@
---
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/timetracker/db.sqlite3"
ports:
- "8000:8000"
restart: unless-stopped

View File

@ -1,30 +1,17 @@
--- ---
services: services:
backend: timetracker:
image: registry.kucharczyk.xyz/timetracker image: registry.kucharczyk.xyz/timetracker
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: timetracker
environment: environment:
- TZ=Europe/Prague - TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000" user: "1000"
volumes: # volumes:
- "static-files:/var/www/django/static" # - "db:/home/timetracker/app/src/web/db.sqlite3"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy:ro"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: restart: unless-stopped
- backend
volumes:
static-files:

View File

@ -2,18 +2,12 @@
# Apply database migrations # Apply database migrations
set -euo pipefail set -euo pipefail
echo "Apply database migrations" echo "Apply database migrations"
poetry run python manage.py migrate poetry run python src/web/manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input poetry run python src/web/manage.py collectstatic
_term() { echo "Starting server"
echo "Caught SIGTERM signal!" caddy start
kill -SIGTERM "$gunicorn_pid" cd src/web || exit
} poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
trap _term SIGTERM
echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid"

File diff suppressed because it is too large Load Diff

View File

@ -1,225 +0,0 @@
name,platform,start,end
Nioh 2,PS5,2022-12-17 19:34,2022-12-17 22:53
Nioh 2,PS5,2022-12-15 02:25,2022-12-15 03:57
Nioh 2,PS5,2022-12-13 02:41,2022-12-13 04:34
Nioh 2,PS5,2022-12-11 21:01,2022-12-11 23:21
VALKYRIE ELYSIUM,PS5,2022-12-11 06:07,2022-12-11 06:20
Metal: Hellsinger,PS5,2022-12-11 05:50,2022-12-11 06:07
Nioh 2,PS5,2022-12-11 04:31,2022-12-11 05:50
Nioh 2,PS5,2022-12-11 04:11,2022-12-11 04:26
Forspoken,PS5,2022-12-10 22:29,2022-12-10 23:10
Nioh 2,PS5,2022-12-10 19:44,2022-12-10 22:29
Nioh 2,PS5,2022-12-09 02:14,2022-12-09 04:16
Nioh 2,PS5,2022-12-08 01:03,2022-12-08 01:57
Nioh 2,PS5,2022-12-07 00:43,2022-12-07 04:16
Nioh 2,PS5,2022-12-04 20:48,2022-12-04 23:31
Nioh 2,PS5,2022-12-04 04:26,2022-12-04 07:01
Nioh 2,PS5,2022-12-04 04:20,2022-12-04 04:22
Nioh 2,PS5,2022-11-26 19:18,2022-11-26 21:28
Nioh 2,PS5,2022-11-26 19:16,2022-11-26 19:18
Nioh 2,PS5,2022-11-26 02:46,2022-11-26 03:56
Nioh 2,PS5,2022-11-26 02:01,2022-11-26 02:43
God of War Ragnarök,PS5,2022-11-24 23:03,2022-11-25 01:32
God of War Ragnarök,PS5,2022-11-23 00:41,2022-11-23 07:52
God of War Ragnarök,PS5,2022-11-21 22:52,2022-11-22 04:51
God of War Ragnarök,PS5,2022-11-21 02:11,2022-11-21 05:13
God of War Ragnarök,PS5,2022-11-20 21:34,2022-11-20 22:50
God of War Ragnarök,PS5,2022-11-20 03:46,2022-11-20 05:52
God of War Ragnarök,PS5,2022-11-19 14:30,2022-11-19 16:14
God of War Ragnarök,PS5,2022-11-18 23:15,2022-11-19 04:16
God of War Ragnarök,PS5,2022-11-18 19:58,2022-11-18 20:40
God of War Ragnarök,PS5,2022-11-18 03:50,2022-11-18 06:25
God of War Ragnarök,PS5,2022-11-17 19:36,2022-11-18 00:26
God of War Ragnarök,PS5,2022-11-17 13:16,2022-11-17 16:13
God of War Ragnarök,PS5,2022-11-16 21:45,2022-11-16 22:38
God of War Ragnarök,PS5,2022-11-16 00:14,2022-11-16 04:28
God of War Ragnarök,PS5,2022-11-15 01:33,2022-11-15 05:06
God of War Ragnarök,PS5,2022-11-14 00:37,2022-11-14 04:43
God of War Ragnarök,PS5,2022-11-12 23:32,2022-11-13 03:45
God of War Ragnarök,PS5,2022-11-12 03:17,2022-11-12 05:00
Grand Theft Auto V (PlayStation®5),PS5,2022-10-03 02:01,2022-10-03 02:23
Grand Theft Auto V (PlayStation®5),PS5,2022-10-02 13:59,2022-10-02 15:54
Grand Theft Auto V (PlayStation®5),PS5,2022-09-30 22:40,2022-10-01 02:50
Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 22:38,2022-09-28 00:16
Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 19:27,2022-09-27 21:09
Grand Theft Auto V (PlayStation®5),PS5,2022-09-26 20:58,2022-09-26 23:38
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 23:56,2022-09-26 02:36
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 14:57,2022-09-25 16:38
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 02:04,2022-09-25 02:12
Grand Theft Auto V (PlayStation®5),PS5,2022-09-23 20:33,2022-09-23 23:38
Wo Long: Fallen Dynasty,PS5,2022-09-18 15:26,2022-09-18 16:58
Grand Theft Auto V (PlayStation®5),PS5,2022-08-31 00:42,2022-08-31 01:15
Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 13:43,2022-08-18 15:12
Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 00:42,2022-08-18 01:58
Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-18 00:40,2022-08-18 00:42
Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-14 15:46,2022-08-14 16:10
FINAL FANTASY VII,PS5,2022-07-26 18:56,2022-07-26 20:22
FINAL FANTASY VII REMAKE,PS5,2022-07-26 17:39,2022-07-26 18:53
FINAL FANTASY VII REMAKE,PS5,2022-07-25 22:12,2022-07-26 04:37
FINAL FANTASY VII REMAKE,PS5,2022-07-24 00:09,2022-07-24 05:33
FINAL FANTASY VII REMAKE,PS5,2022-07-23 23:34,2022-07-23 23:48
FINAL FANTASY VII REMAKE,PS5,2022-07-23 18:05,2022-07-23 19:44
FINAL FANTASY VII REMAKE,PS5,2022-07-22 17:07,2022-07-23 01:48
FINAL FANTASY VII REMAKE,PS5,2022-07-22 15:26,2022-07-22 15:59
FINAL FANTASY VII REMAKE,PS5,2022-07-21 21:27,2022-07-21 22:43
FINAL FANTASY VII REMAKE,PS5,2022-07-21 20:48,2022-07-21 20:58
FINAL FANTASY VII REMAKE,PS5,2022-07-21 18:35,2022-07-21 18:36
Stray,PS5,2022-07-21 01:24,2022-07-21 02:34
Stray,PS5,2022-07-19 23:49,2022-07-20 03:24
Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-04 03:26,2022-07-04 03:34
Red Dead Redemption,PS3,2022-07-04 02:36,2022-07-04 03:14
Ghost of Tsushima,PS5,2022-07-04 01:38,2022-07-04 02:34
Dark Cloud™,PS4,2022-07-01 23:48,2022-07-02 00:04
Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-01 23:20,2022-07-01 23:46
Resident Evil Directors Cut,PS5,2022-07-01 23:14,2022-07-01 23:20
ELEX II,PS5,2022-07-01 22:48,2022-07-01 23:13
OlliOlli World,PS5,2022-07-01 21:30,2022-07-01 22:30
Deep Rock Galactic,PS5,2022-06-16 05:30,2022-06-16 06:14
Curse of the Dead Gods,PS5,2022-06-16 05:00,2022-06-16 05:22
Persona 5: Dancing in Starlight,PS5,2022-04-29 20:14,2022-04-29 20:15
Persona 5: Dancing in Starlight,PS5,2022-04-29 00:18,2022-04-29 00:44
Dying Light 2,PS5,2022-04-14 01:26,2022-04-14 01:27
Grand Theft Auto V (PlayStation®5),PS5,2022-03-24 16:26,2022-03-24 16:27
Grand Theft Auto V (PlayStation®5),PS5,2022-03-21 15:52,2022-03-21 15:59
Horizon Forbidden West,PS5,2022-02-23 19:37,2022-02-24 00:24
Horizon Forbidden West,PS5,2022-02-23 13:57,2022-02-23 17:44
Horizon Forbidden West,PS5,2022-02-22 18:05,2022-02-23 05:26
Horizon Forbidden West,PS5,2022-02-22 15:39,2022-02-22 17:02
Horizon Forbidden West,PS5,2022-02-22 00:05,2022-02-22 04:08
Horizon Forbidden West,PS5,2022-02-20 15:39,2022-02-20 23:08
Horizon Forbidden West,PS5,2022-02-20 14:54,2022-02-20 15:09
Horizon Forbidden West,PS5,2022-02-19 23:37,2022-02-20 04:45
Horizon Forbidden West,PS5,2022-02-18 23:15,2022-02-19 03:27
Assassin's Creed® Origins,PS5,2022-02-18 21:49,2022-02-18 23:15
Assassin's Creed® Origins,PS5,2022-01-17 02:38,2022-01-17 02:50
Deep Rock Galactic,PS5,2022-01-17 00:57,2022-01-17 02:35
HITMAN 3,PS5,2021-11-17 00:35,2021-11-17 01:17
HITMAN 3,PS5,2021-11-08 01:59,2021-11-08 06:17
HITMAN 3,PS5,2021-11-07 03:10,2021-11-07 05:23
HITMAN 3,PS5,2021-11-06 04:23,2021-11-06 08:49
HITMAN 3,PS5,2021-11-06 02:17,2021-11-06 03:31
HITMAN 3,PS5,2021-11-05 21:33,2021-11-05 23:24
HITMAN 3,PS5,2021-11-05 03:09,2021-11-05 03:34
HITMAN 3,PS5,2021-11-05 00:47,2021-11-05 02:26
HITMAN 3,PS5,2021-11-04 20:27,2021-11-04 23:32
HITMAN 3,PS5,2021-11-04 01:34,2021-11-04 05:33
RESIDENT EVIL 3,PS5,2021-11-03 23:14,2021-11-03 23:56
RESIDENT EVIL 3,PS5,2021-11-02 23:56,2021-11-03 05:10
RESIDENT EVIL 3,PS5,2021-11-02 21:22,2021-11-02 23:23
RESIDENT EVIL 3,PS5,2021-11-02 05:36,2021-11-02 06:56
HITMAN 3,PS5,2021-11-02 03:00,2021-11-02 05:36
HITMAN 3,PS5,2021-11-02 01:19,2021-11-02 01:25
HITMAN™ 2,PS5,2021-11-02 01:09,2021-11-02 01:19
HITMAN 3,PS5,2021-11-01 23:45,2021-11-02 01:09
RESIDENT EVIL 3,PS5,2021-11-01 19:32,2021-11-01 19:47
Marvel's Spider-Man: Miles Morales,PS5,2021-10-17 01:06,2021-10-17 03:27
Marvel's Spider-Man: Miles Morales,PS5,2021-10-16 20:58,2021-10-16 22:00
Marvel's Spider-Man: Miles Morales,PS5,2021-10-05 02:30,2021-10-05 03:27
Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 23:12,2021-10-04 01:21
Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 03:02,2021-10-03 04:42
Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 20:12,2021-10-02 21:10
Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 01:40,2021-10-02 03:36
Marvel's Spider-Man: Miles Morales,PS5,2021-10-01 04:34,2021-10-01 05:30
DEATHLOOP,PS5,2021-10-01 01:12,2021-10-01 04:27
DEATHLOOP,PS5,2021-09-30 03:04,2021-09-30 06:30
DEATHLOOP,PS5,2021-09-29 00:28,2021-09-29 05:08
Persona 5 Royal,PS5,2021-09-28 00:36,2021-09-28 03:08
Persona 5 Royal,PS5,2021-09-27 02:16,2021-09-27 05:56
Persona 5 Royal,PS5,2021-09-26 14:54,2021-09-26 16:32
Persona 5 Royal,PS5,2021-09-25 18:43,2021-09-25 23:26
Persona 5 Royal,PS5,2021-09-24 21:41,2021-09-25 03:40
Persona 5 Royal,PS5,2021-09-23 00:18,2021-09-23 06:26
Persona 5 Royal,PS5,2021-09-21 20:27,2021-09-22 05:43
Persona 5 Royal,PS5,2021-09-21 01:07,2021-09-21 06:06
Borderlands: The Handsome Collection,PS5,2021-09-20 23:59,2021-09-21 01:07
Persona 5 Royal,PS5,2021-09-20 23:53,2021-09-20 23:59
DEATHLOOP,PS5,2021-09-20 02:03,2021-09-20 06:29
DEATHLOOP,PS5,2021-09-19 19:49,2021-09-20 01:16
Borderlands: The Handsome Collection,PS5,2021-09-19 00:51,2021-09-19 03:41
Borderlands: The Handsome Collection,PS5,2021-09-17 23:45,2021-09-18 01:48
Borderlands: The Handsome Collection,PS5,2021-09-17 23:40,2021-09-17 23:41
DEATHLOOP,PS5,2021-09-17 16:48,2021-09-17 18:56
DEATHLOOP,PS5,2021-09-17 03:02,2021-09-17 04:39
DEATHLOOP,PS5,2021-09-17 00:03,2021-09-17 02:53
DEATHLOOP,PS5,2021-09-16 18:39,2021-09-16 21:12
Persona 5 Royal,PS5,2021-09-16 18:29,2021-09-16 18:30
Persona 5 Royal,PS5,2021-09-16 02:26,2021-09-16 06:13
Persona 5 Royal,PS5,2021-09-16 02:20,2021-09-16 02:21
Persona 5 Royal,PS5,2021-09-15 01:48,2021-09-15 06:07
Persona 5 Royal,PS5,2021-09-14 22:21,2021-09-15 01:22
Persona 5 Royal,PS5,2021-09-14 02:01,2021-09-14 05:48
Persona 5 Royal,PS5,2021-09-14 00:24,2021-09-14 01:46
Persona 5 Royal,PS5,2021-08-12 05:04,2021-08-12 07:05
Persona 5 Royal,PS5,2021-08-11 05:02,2021-08-11 06:48
Persona 5 Royal,PS5,2021-08-09 00:37,2021-08-09 06:15
Persona 5 Royal,PS5,2021-08-08 00:31,2021-08-08 08:01
Persona 5 Royal,PS5,2021-08-07 19:51,2021-08-07 22:50
Persona 5 Royal,PS5,2021-08-06 23:51,2021-08-07 01:35
Persona 5 Royal,PS5,2021-08-06 19:26,2021-08-06 22:26
Persona 5 Royal,PS5,2021-08-06 02:42,2021-08-06 06:51
Persona 5 Royal,PS5,2021-08-06 00:37,2021-08-06 01:54
Far Cry® 5,PS5,2021-08-01 23:27,2021-08-02 02:09
Far Cry® 5,PS5,2021-08-01 18:10,2021-08-01 19:40
STAR WARS™: Squadrons,PS5,2021-08-01 18:02,2021-08-01 18:10
STAR WARS™: Squadrons,PS5,2021-08-01 00:24,2021-08-01 00:30
STEEP,PS5,2021-08-01 00:15,2021-08-01 00:24
Red Dead Redemption 2,PS5,2021-07-31 23:48,2021-08-01 00:13
Persona 5 Royal,PS5,2021-07-30 19:09,2021-07-30 19:10
Persona 5 Royal,PS5,2021-07-29 03:41,2021-07-29 04:59
Persona 5 Royal,PS5,2021-07-28 02:32,2021-07-28 03:07
Demon's Souls,PS5,2021-07-28 00:12,2021-07-28 02:32
Red Dead Redemption 2,PS5,2021-07-27 23:20,2021-07-27 23:23
Red Dead Redemption 2,PS5,2021-07-26 00:42,2021-07-26 01:13
Ghost of Tsushima,PS5,2021-07-25 19:03,2021-07-25 22:12
Ghost of Tsushima,PS5,2021-07-25 18:52,2021-07-25 18:55
Rez Infinite,PS5,2021-07-25 18:32,2021-07-25 18:52
Returnal,PS5,2021-07-25 05:24,2021-07-25 05:26
Tom Clancy's The Division® 2,PS5,2021-07-25 02:12,2021-07-25 05:24
Returnal,PS5,2021-07-25 00:00,2021-07-25 02:12
Returnal,PS5,2021-07-24 16:13,2021-07-24 17:39
Returnal,PS5,2021-07-24 03:02,2021-07-24 07:02
Returnal,PS5,2021-07-23 18:08,2021-07-23 20:39
Returnal,PS5,2021-07-23 14:36,2021-07-23 15:38
Titanfall™ 2,PS5,2021-07-22 02:42,2021-07-22 03:20
Returnal,PS5,2021-07-20 02:12,2021-07-20 05:40
Returnal,PS5,2021-07-19 03:37,2021-07-19 05:24
Concrete Genie,PS5,2021-07-19 03:35,2021-07-19 03:37
Concrete Genie,PS5,2021-07-18 05:04,2021-07-18 05:30
Stranded Deep,PS5,2021-07-18 04:32,2021-07-18 04:58
Sniper Elite 4,PS5,2021-07-18 04:16,2021-07-18 04:32
Oddworld: Soulstorm,PS5,2021-07-18 04:00,2021-07-18 04:14
Zombie Army 4: Dead War,PS5,2021-07-18 03:48,2021-07-18 03:58
Sekiro™: Shadows Die Twice,PS5,2021-07-18 03:00,2021-07-18 03:48
Returnal,PS5,2021-07-17 17:41,2021-07-17 23:32
Returnal,PS5,2021-07-17 01:35,2021-07-17 06:07
Returnal,PS5,2021-07-17 00:23,2021-07-17 01:21
Another World - 20th Anniversary Edition,PS5,2021-07-15 22:09,2021-07-15 23:33
Sekiro™: Shadows Die Twice,PS5,2021-07-15 21:52,2021-07-15 22:09
Sekiro™: Shadows Die Twice,PS5,2021-07-15 19:07,2021-07-15 20:13
Sekiro™: Shadows Die Twice,PS5,2021-07-15 01:50,2021-07-15 03:31
Sekiro™: Shadows Die Twice,PS5,2021-07-15 00:12,2021-07-15 01:14
Sekiro™: Shadows Die Twice,PS5,2021-07-14 03:19,2021-07-14 05:13
Persona 5,PS5,2021-07-13 21:56,2021-07-13 21:58
Persona 5,PS5,2021-07-13 01:32,2021-07-13 02:59
Maquette,PS5,2021-07-13 01:30,2021-07-13 01:32
Maquette,PS5,2021-07-12 23:59,2021-07-13 00:34
ASTRO's PLAYROOM,PS5,2021-07-12 23:06,2021-07-12 23:59
Crash Bandicoot N. Sane Trilogy,PS5,2021-07-12 23:01,2021-07-12 23:06
Virtua Fighter 5 Ultimate Showdown,PS4,2021-07-02 19:46,2021-07-02 20:57
Bloodborne™,PS4,2021-04-03 19:50,2021-04-03 23:52
Tom Clancy's The Division® 2,PS4,2021-04-03 02:10,2021-04-03 05:22
Bloodborne™,PS4,2021-04-02 21:34,2021-04-03 02:10
Bloodborne™,PS4,2021-04-02 06:01,2021-04-02 08:11
Tom Clancy's The Division® 2,PS4,2021-04-02 04:24,2021-04-02 06:01
Tom Clancy's The Division® 2,PS4,2021-03-31 02:55,2021-03-31 05:50
Tom Clancy's The Division® 2,PS4,2021-03-29 02:00,2021-03-29 02:02
Tom Clancy's The Division® 2,PS4,2021-03-27 03:31,2021-03-27 06:29
Tom Clancy's The Division® 2,PS4,2021-03-26 05:10,2021-03-26 06:03
Remnant: From the Ashes,PS4,2021-03-23 03:20,2021-03-23 05:58
DARK SOULS™ II: Scholar of the First Sin,PS4,2021-03-20 01:31,2021-03-20 01:34
Remnant: From the Ashes,PS4,2021-03-12 01:29,2021-03-12 01:30
Remnant: From the Ashes,PS4,2021-03-08 02:41,2021-03-08 05:38
Remnant: From the Ashes,PS4,2021-03-07 03:21,2021-03-07 06:49
13 Sentinels: Aegis Rim,PS4,2021-03-07 03:20,2021-03-07 03:21
DARK SOULS™ II: Scholar of the First Sin,PS4,2020-10-24 23:43,2020-10-25 01:18
Ghost of Tsushima,PS4,2020-10-24 23:14,2020-10-24 23:22
1 name platform start end
2 Nioh 2 PS5 2022-12-17 19:34 2022-12-17 22:53
3 Nioh 2 PS5 2022-12-15 02:25 2022-12-15 03:57
4 Nioh 2 PS5 2022-12-13 02:41 2022-12-13 04:34
5 Nioh 2 PS5 2022-12-11 21:01 2022-12-11 23:21
6 VALKYRIE ELYSIUM PS5 2022-12-11 06:07 2022-12-11 06:20
7 Metal: Hellsinger PS5 2022-12-11 05:50 2022-12-11 06:07
8 Nioh 2 PS5 2022-12-11 04:31 2022-12-11 05:50
9 Nioh 2 PS5 2022-12-11 04:11 2022-12-11 04:26
10 Forspoken PS5 2022-12-10 22:29 2022-12-10 23:10
11 Nioh 2 PS5 2022-12-10 19:44 2022-12-10 22:29
12 Nioh 2 PS5 2022-12-09 02:14 2022-12-09 04:16
13 Nioh 2 PS5 2022-12-08 01:03 2022-12-08 01:57
14 Nioh 2 PS5 2022-12-07 00:43 2022-12-07 04:16
15 Nioh 2 PS5 2022-12-04 20:48 2022-12-04 23:31
16 Nioh 2 PS5 2022-12-04 04:26 2022-12-04 07:01
17 Nioh 2 PS5 2022-12-04 04:20 2022-12-04 04:22
18 Nioh 2 PS5 2022-11-26 19:18 2022-11-26 21:28
19 Nioh 2 PS5 2022-11-26 19:16 2022-11-26 19:18
20 Nioh 2 PS5 2022-11-26 02:46 2022-11-26 03:56
21 Nioh 2 PS5 2022-11-26 02:01 2022-11-26 02:43
22 God of War Ragnarök PS5 2022-11-24 23:03 2022-11-25 01:32
23 God of War Ragnarök PS5 2022-11-23 00:41 2022-11-23 07:52
24 God of War Ragnarök PS5 2022-11-21 22:52 2022-11-22 04:51
25 God of War Ragnarök PS5 2022-11-21 02:11 2022-11-21 05:13
26 God of War Ragnarök PS5 2022-11-20 21:34 2022-11-20 22:50
27 God of War Ragnarök PS5 2022-11-20 03:46 2022-11-20 05:52
28 God of War Ragnarök PS5 2022-11-19 14:30 2022-11-19 16:14
29 God of War Ragnarök PS5 2022-11-18 23:15 2022-11-19 04:16
30 God of War Ragnarök PS5 2022-11-18 19:58 2022-11-18 20:40
31 God of War Ragnarök PS5 2022-11-18 03:50 2022-11-18 06:25
32 God of War Ragnarök PS5 2022-11-17 19:36 2022-11-18 00:26
33 God of War Ragnarök PS5 2022-11-17 13:16 2022-11-17 16:13
34 God of War Ragnarök PS5 2022-11-16 21:45 2022-11-16 22:38
35 God of War Ragnarök PS5 2022-11-16 00:14 2022-11-16 04:28
36 God of War Ragnarök PS5 2022-11-15 01:33 2022-11-15 05:06
37 God of War Ragnarök PS5 2022-11-14 00:37 2022-11-14 04:43
38 God of War Ragnarök PS5 2022-11-12 23:32 2022-11-13 03:45
39 God of War Ragnarök PS5 2022-11-12 03:17 2022-11-12 05:00
40 Grand Theft Auto V (PlayStation®5) PS5 2022-10-03 02:01 2022-10-03 02:23
41 Grand Theft Auto V (PlayStation®5) PS5 2022-10-02 13:59 2022-10-02 15:54
42 Grand Theft Auto V (PlayStation®5) PS5 2022-09-30 22:40 2022-10-01 02:50
43 Grand Theft Auto V (PlayStation®5) PS5 2022-09-27 22:38 2022-09-28 00:16
44 Grand Theft Auto V (PlayStation®5) PS5 2022-09-27 19:27 2022-09-27 21:09
45 Grand Theft Auto V (PlayStation®5) PS5 2022-09-26 20:58 2022-09-26 23:38
46 Grand Theft Auto V (PlayStation®5) PS5 2022-09-25 23:56 2022-09-26 02:36
47 Grand Theft Auto V (PlayStation®5) PS5 2022-09-25 14:57 2022-09-25 16:38
48 Grand Theft Auto V (PlayStation®5) PS5 2022-09-25 02:04 2022-09-25 02:12
49 Grand Theft Auto V (PlayStation®5) PS5 2022-09-23 20:33 2022-09-23 23:38
50 Wo Long: Fallen Dynasty PS5 2022-09-18 15:26 2022-09-18 16:58
51 Grand Theft Auto V (PlayStation®5) PS5 2022-08-31 00:42 2022-08-31 01:15
52 Grand Theft Auto V (PlayStation®5) PS5 2022-08-18 13:43 2022-08-18 15:12
53 Grand Theft Auto V (PlayStation®5) PS5 2022-08-18 00:42 2022-08-18 01:58
54 Tony Hawk's™ Pro Skater™ 1 + 2 PS5 2022-08-18 00:40 2022-08-18 00:42
55 Tony Hawk's™ Pro Skater™ 1 + 2 PS5 2022-08-14 15:46 2022-08-14 16:10
56 FINAL FANTASY VII PS5 2022-07-26 18:56 2022-07-26 20:22
57 FINAL FANTASY VII REMAKE PS5 2022-07-26 17:39 2022-07-26 18:53
58 FINAL FANTASY VII REMAKE PS5 2022-07-25 22:12 2022-07-26 04:37
59 FINAL FANTASY VII REMAKE PS5 2022-07-24 00:09 2022-07-24 05:33
60 FINAL FANTASY VII REMAKE PS5 2022-07-23 23:34 2022-07-23 23:48
61 FINAL FANTASY VII REMAKE PS5 2022-07-23 18:05 2022-07-23 19:44
62 FINAL FANTASY VII REMAKE PS5 2022-07-22 17:07 2022-07-23 01:48
63 FINAL FANTASY VII REMAKE PS5 2022-07-22 15:26 2022-07-22 15:59
64 FINAL FANTASY VII REMAKE PS5 2022-07-21 21:27 2022-07-21 22:43
65 FINAL FANTASY VII REMAKE PS5 2022-07-21 20:48 2022-07-21 20:58
66 FINAL FANTASY VII REMAKE PS5 2022-07-21 18:35 2022-07-21 18:36
67 Stray PS5 2022-07-21 01:24 2022-07-21 02:34
68 Stray PS5 2022-07-19 23:49 2022-07-20 03:24
69 Atelier Ayesha ~The Alchemist of Dusk~ PS3 2022-07-04 03:26 2022-07-04 03:34
70 Red Dead Redemption PS3 2022-07-04 02:36 2022-07-04 03:14
71 Ghost of Tsushima PS5 2022-07-04 01:38 2022-07-04 02:34
72 Dark Cloud™ PS4 2022-07-01 23:48 2022-07-02 00:04
73 Atelier Ayesha ~The Alchemist of Dusk~ PS3 2022-07-01 23:20 2022-07-01 23:46
74 Resident Evil Director’s Cut PS5 2022-07-01 23:14 2022-07-01 23:20
75 ELEX II PS5 2022-07-01 22:48 2022-07-01 23:13
76 OlliOlli World PS5 2022-07-01 21:30 2022-07-01 22:30
77 Deep Rock Galactic PS5 2022-06-16 05:30 2022-06-16 06:14
78 Curse of the Dead Gods PS5 2022-06-16 05:00 2022-06-16 05:22
79 Persona 5: Dancing in Starlight PS5 2022-04-29 20:14 2022-04-29 20:15
80 Persona 5: Dancing in Starlight PS5 2022-04-29 00:18 2022-04-29 00:44
81 Dying Light 2 PS5 2022-04-14 01:26 2022-04-14 01:27
82 Grand Theft Auto V (PlayStation®5) PS5 2022-03-24 16:26 2022-03-24 16:27
83 Grand Theft Auto V (PlayStation®5) PS5 2022-03-21 15:52 2022-03-21 15:59
84 Horizon Forbidden West PS5 2022-02-23 19:37 2022-02-24 00:24
85 Horizon Forbidden West PS5 2022-02-23 13:57 2022-02-23 17:44
86 Horizon Forbidden West PS5 2022-02-22 18:05 2022-02-23 05:26
87 Horizon Forbidden West PS5 2022-02-22 15:39 2022-02-22 17:02
88 Horizon Forbidden West PS5 2022-02-22 00:05 2022-02-22 04:08
89 Horizon Forbidden West PS5 2022-02-20 15:39 2022-02-20 23:08
90 Horizon Forbidden West PS5 2022-02-20 14:54 2022-02-20 15:09
91 Horizon Forbidden West PS5 2022-02-19 23:37 2022-02-20 04:45
92 Horizon Forbidden West PS5 2022-02-18 23:15 2022-02-19 03:27
93 Assassin's Creed® Origins PS5 2022-02-18 21:49 2022-02-18 23:15
94 Assassin's Creed® Origins PS5 2022-01-17 02:38 2022-01-17 02:50
95 Deep Rock Galactic PS5 2022-01-17 00:57 2022-01-17 02:35
96 HITMAN 3 PS5 2021-11-17 00:35 2021-11-17 01:17
97 HITMAN 3 PS5 2021-11-08 01:59 2021-11-08 06:17
98 HITMAN 3 PS5 2021-11-07 03:10 2021-11-07 05:23
99 HITMAN 3 PS5 2021-11-06 04:23 2021-11-06 08:49
100 HITMAN 3 PS5 2021-11-06 02:17 2021-11-06 03:31
101 HITMAN 3 PS5 2021-11-05 21:33 2021-11-05 23:24
102 HITMAN 3 PS5 2021-11-05 03:09 2021-11-05 03:34
103 HITMAN 3 PS5 2021-11-05 00:47 2021-11-05 02:26
104 HITMAN 3 PS5 2021-11-04 20:27 2021-11-04 23:32
105 HITMAN 3 PS5 2021-11-04 01:34 2021-11-04 05:33
106 RESIDENT EVIL 3 PS5 2021-11-03 23:14 2021-11-03 23:56
107 RESIDENT EVIL 3 PS5 2021-11-02 23:56 2021-11-03 05:10
108 RESIDENT EVIL 3 PS5 2021-11-02 21:22 2021-11-02 23:23
109 RESIDENT EVIL 3 PS5 2021-11-02 05:36 2021-11-02 06:56
110 HITMAN 3 PS5 2021-11-02 03:00 2021-11-02 05:36
111 HITMAN 3 PS5 2021-11-02 01:19 2021-11-02 01:25
112 HITMAN™ 2 PS5 2021-11-02 01:09 2021-11-02 01:19
113 HITMAN 3 PS5 2021-11-01 23:45 2021-11-02 01:09
114 RESIDENT EVIL 3 PS5 2021-11-01 19:32 2021-11-01 19:47
115 Marvel's Spider-Man: Miles Morales PS5 2021-10-17 01:06 2021-10-17 03:27
116 Marvel's Spider-Man: Miles Morales PS5 2021-10-16 20:58 2021-10-16 22:00
117 Marvel's Spider-Man: Miles Morales PS5 2021-10-05 02:30 2021-10-05 03:27
118 Marvel's Spider-Man: Miles Morales PS5 2021-10-03 23:12 2021-10-04 01:21
119 Marvel's Spider-Man: Miles Morales PS5 2021-10-03 03:02 2021-10-03 04:42
120 Marvel's Spider-Man: Miles Morales PS5 2021-10-02 20:12 2021-10-02 21:10
121 Marvel's Spider-Man: Miles Morales PS5 2021-10-02 01:40 2021-10-02 03:36
122 Marvel's Spider-Man: Miles Morales PS5 2021-10-01 04:34 2021-10-01 05:30
123 DEATHLOOP PS5 2021-10-01 01:12 2021-10-01 04:27
124 DEATHLOOP PS5 2021-09-30 03:04 2021-09-30 06:30
125 DEATHLOOP PS5 2021-09-29 00:28 2021-09-29 05:08
126 Persona 5 Royal PS5 2021-09-28 00:36 2021-09-28 03:08
127 Persona 5 Royal PS5 2021-09-27 02:16 2021-09-27 05:56
128 Persona 5 Royal PS5 2021-09-26 14:54 2021-09-26 16:32
129 Persona 5 Royal PS5 2021-09-25 18:43 2021-09-25 23:26
130 Persona 5 Royal PS5 2021-09-24 21:41 2021-09-25 03:40
131 Persona 5 Royal PS5 2021-09-23 00:18 2021-09-23 06:26
132 Persona 5 Royal PS5 2021-09-21 20:27 2021-09-22 05:43
133 Persona 5 Royal PS5 2021-09-21 01:07 2021-09-21 06:06
134 Borderlands: The Handsome Collection PS5 2021-09-20 23:59 2021-09-21 01:07
135 Persona 5 Royal PS5 2021-09-20 23:53 2021-09-20 23:59
136 DEATHLOOP PS5 2021-09-20 02:03 2021-09-20 06:29
137 DEATHLOOP PS5 2021-09-19 19:49 2021-09-20 01:16
138 Borderlands: The Handsome Collection PS5 2021-09-19 00:51 2021-09-19 03:41
139 Borderlands: The Handsome Collection PS5 2021-09-17 23:45 2021-09-18 01:48
140 Borderlands: The Handsome Collection PS5 2021-09-17 23:40 2021-09-17 23:41
141 DEATHLOOP PS5 2021-09-17 16:48 2021-09-17 18:56
142 DEATHLOOP PS5 2021-09-17 03:02 2021-09-17 04:39
143 DEATHLOOP PS5 2021-09-17 00:03 2021-09-17 02:53
144 DEATHLOOP PS5 2021-09-16 18:39 2021-09-16 21:12
145 Persona 5 Royal PS5 2021-09-16 18:29 2021-09-16 18:30
146 Persona 5 Royal PS5 2021-09-16 02:26 2021-09-16 06:13
147 Persona 5 Royal PS5 2021-09-16 02:20 2021-09-16 02:21
148 Persona 5 Royal PS5 2021-09-15 01:48 2021-09-15 06:07
149 Persona 5 Royal PS5 2021-09-14 22:21 2021-09-15 01:22
150 Persona 5 Royal PS5 2021-09-14 02:01 2021-09-14 05:48
151 Persona 5 Royal PS5 2021-09-14 00:24 2021-09-14 01:46
152 Persona 5 Royal PS5 2021-08-12 05:04 2021-08-12 07:05
153 Persona 5 Royal PS5 2021-08-11 05:02 2021-08-11 06:48
154 Persona 5 Royal PS5 2021-08-09 00:37 2021-08-09 06:15
155 Persona 5 Royal PS5 2021-08-08 00:31 2021-08-08 08:01
156 Persona 5 Royal PS5 2021-08-07 19:51 2021-08-07 22:50
157 Persona 5 Royal PS5 2021-08-06 23:51 2021-08-07 01:35
158 Persona 5 Royal PS5 2021-08-06 19:26 2021-08-06 22:26
159 Persona 5 Royal PS5 2021-08-06 02:42 2021-08-06 06:51
160 Persona 5 Royal PS5 2021-08-06 00:37 2021-08-06 01:54
161 Far Cry® 5 PS5 2021-08-01 23:27 2021-08-02 02:09
162 Far Cry® 5 PS5 2021-08-01 18:10 2021-08-01 19:40
163 STAR WARS™: Squadrons PS5 2021-08-01 18:02 2021-08-01 18:10
164 STAR WARS™: Squadrons PS5 2021-08-01 00:24 2021-08-01 00:30
165 STEEP PS5 2021-08-01 00:15 2021-08-01 00:24
166 Red Dead Redemption 2 PS5 2021-07-31 23:48 2021-08-01 00:13
167 Persona 5 Royal PS5 2021-07-30 19:09 2021-07-30 19:10
168 Persona 5 Royal PS5 2021-07-29 03:41 2021-07-29 04:59
169 Persona 5 Royal PS5 2021-07-28 02:32 2021-07-28 03:07
170 Demon's Souls PS5 2021-07-28 00:12 2021-07-28 02:32
171 Red Dead Redemption 2 PS5 2021-07-27 23:20 2021-07-27 23:23
172 Red Dead Redemption 2 PS5 2021-07-26 00:42 2021-07-26 01:13
173 Ghost of Tsushima PS5 2021-07-25 19:03 2021-07-25 22:12
174 Ghost of Tsushima PS5 2021-07-25 18:52 2021-07-25 18:55
175 Rez Infinite PS5 2021-07-25 18:32 2021-07-25 18:52
176 Returnal PS5 2021-07-25 05:24 2021-07-25 05:26
177 Tom Clancy's The Division® 2 PS5 2021-07-25 02:12 2021-07-25 05:24
178 Returnal PS5 2021-07-25 00:00 2021-07-25 02:12
179 Returnal PS5 2021-07-24 16:13 2021-07-24 17:39
180 Returnal PS5 2021-07-24 03:02 2021-07-24 07:02
181 Returnal PS5 2021-07-23 18:08 2021-07-23 20:39
182 Returnal PS5 2021-07-23 14:36 2021-07-23 15:38
183 Titanfall™ 2 PS5 2021-07-22 02:42 2021-07-22 03:20
184 Returnal PS5 2021-07-20 02:12 2021-07-20 05:40
185 Returnal PS5 2021-07-19 03:37 2021-07-19 05:24
186 Concrete Genie PS5 2021-07-19 03:35 2021-07-19 03:37
187 Concrete Genie PS5 2021-07-18 05:04 2021-07-18 05:30
188 Stranded Deep PS5 2021-07-18 04:32 2021-07-18 04:58
189 Sniper Elite 4 PS5 2021-07-18 04:16 2021-07-18 04:32
190 Oddworld: Soulstorm PS5 2021-07-18 04:00 2021-07-18 04:14
191 Zombie Army 4: Dead War PS5 2021-07-18 03:48 2021-07-18 03:58
192 Sekiro™: Shadows Die Twice PS5 2021-07-18 03:00 2021-07-18 03:48
193 Returnal PS5 2021-07-17 17:41 2021-07-17 23:32
194 Returnal PS5 2021-07-17 01:35 2021-07-17 06:07
195 Returnal PS5 2021-07-17 00:23 2021-07-17 01:21
196 Another World - 20th Anniversary Edition PS5 2021-07-15 22:09 2021-07-15 23:33
197 Sekiro™: Shadows Die Twice PS5 2021-07-15 21:52 2021-07-15 22:09
198 Sekiro™: Shadows Die Twice PS5 2021-07-15 19:07 2021-07-15 20:13
199 Sekiro™: Shadows Die Twice PS5 2021-07-15 01:50 2021-07-15 03:31
200 Sekiro™: Shadows Die Twice PS5 2021-07-15 00:12 2021-07-15 01:14
201 Sekiro™: Shadows Die Twice PS5 2021-07-14 03:19 2021-07-14 05:13
202 Persona 5 PS5 2021-07-13 21:56 2021-07-13 21:58
203 Persona 5 PS5 2021-07-13 01:32 2021-07-13 02:59
204 Maquette PS5 2021-07-13 01:30 2021-07-13 01:32
205 Maquette PS5 2021-07-12 23:59 2021-07-13 00:34
206 ASTRO's PLAYROOM PS5 2021-07-12 23:06 2021-07-12 23:59
207 Crash Bandicoot N. Sane Trilogy PS5 2021-07-12 23:01 2021-07-12 23:06
208 Virtua Fighter 5 Ultimate Showdown PS4 2021-07-02 19:46 2021-07-02 20:57
209 Bloodborne™ PS4 2021-04-03 19:50 2021-04-03 23:52
210 Tom Clancy's The Division® 2 PS4 2021-04-03 02:10 2021-04-03 05:22
211 Bloodborne™ PS4 2021-04-02 21:34 2021-04-03 02:10
212 Bloodborne™ PS4 2021-04-02 06:01 2021-04-02 08:11
213 Tom Clancy's The Division® 2 PS4 2021-04-02 04:24 2021-04-02 06:01
214 Tom Clancy's The Division® 2 PS4 2021-03-31 02:55 2021-03-31 05:50
215 Tom Clancy's The Division® 2 PS4 2021-03-29 02:00 2021-03-29 02:02
216 Tom Clancy's The Division® 2 PS4 2021-03-27 03:31 2021-03-27 06:29
217 Tom Clancy's The Division® 2 PS4 2021-03-26 05:10 2021-03-26 06:03
218 Remnant: From the Ashes PS4 2021-03-23 03:20 2021-03-23 05:58
219 DARK SOULS™ II: Scholar of the First Sin PS4 2021-03-20 01:31 2021-03-20 01:34
220 Remnant: From the Ashes PS4 2021-03-12 01:29 2021-03-12 01:30
221 Remnant: From the Ashes PS4 2021-03-08 02:41 2021-03-08 05:38
222 Remnant: From the Ashes PS4 2021-03-07 03:21 2021-03-07 06:49
223 13 Sentinels: Aegis Rim PS4 2021-03-07 03:20 2021-03-07 03:21
224 DARK SOULS™ II: Scholar of the First Sin PS4 2020-10-24 23:43 2020-10-25 01:18
225 Ghost of Tsushima PS4 2020-10-24 23:14 2020-10-24 23:22

View File

@ -1,71 +0,0 @@
- model: games.game
pk: 1
fields:
name: Nioh 2
wikidata: Q67482292
- model: games.game
pk: 2
fields:
name: Elden Ring
wikidata: Q64826862
- model: games.game
pk: 3
fields:
name: Cyberpunk 2077
wikidata: Q3182559
- model: games.purchase
pk: 1
fields:
game: 1
platform: 1
date_purchased: 2021-02-13
date_refunded: null
- model: games.purchase
pk: 2
fields:
game: 2
platform: 1
date_purchased: 2022-02-24
date_refunded: null
- model: games.purchase
pk: 3
fields:
game: 3
platform: 1
date_purchased: 2020-12-07
date_refunded: null
- model: games.platform
pk: 1
fields:
name: Steam
group: PC
- model: games.platform
pk: 3
fields:
name: Xbox Gamepass
group: PC
- model: games.platform
pk: 4
fields:
name: Epic Games Store
group: PC
- model: games.platform
pk: 5
fields:
name: Playstation 5
group: Playstation
- model: games.platform
pk: 6
fields:
name: Playstation 4
group: Playstation
- model: games.platform
pk: 7
fields:
name: Nintendo Switch
group: Nintendo
- model: games.platform
pk: 8
fields:
name: Nintendo 3DS
group: Nintendo

View File

@ -1,171 +0,0 @@
from django import forms
from django.urls import reverse
from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
"timestamp_end": custom_datetime_widget,
}
model = Session
fields = [
"purchase",
"timestamp_start",
"timestamp_end",
"duration_manual",
"device",
"note",
]
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-platform"] = value.instance.platform.id
return option
class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False,
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
}
model = Purchase
fields = [
"edition",
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"price",
"price_currency",
"ownership_type",
"type",
"related_purchase",
"name",
]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-name"] = value.instance.name
option["attrs"]["data-year"] = value.instance.year_released
return option
class GameModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
# Use sort_name as the label for the option
return obj.sort_name
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = ["name", "group"]
widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,51 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,141 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
import django.db.models.deletion
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
replaces = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
("games", "0017_alter_device_type_alter_purchase_platform"),
("games", "0018_auto_20231106_1825"),
("games", "0019_alter_edition_unique_together"),
("games", "0020_game_year"),
("games", "0021_auto_20231106_1909"),
("games", "0022_rename_year_game_year_released"),
]
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.RunPython(
code=rename_duplicates,
),
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(
code=update_game_year,
),
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
]
operations = [
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:25
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
class Migration(migrations.Migration):
dependencies = [
("games", "0017_alter_device_type_alter_purchase_platform"),
]
operations = [
migrations.RunPython(rename_duplicates),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0018_auto_20231106_1825"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
]

View File

@ -1,24 +0,0 @@
from django.db import migrations
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0020_game_year"),
]
operations = [
migrations.RunPython(update_game_year),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0021_auto_20231106_1909"),
]
operations = [
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"games",
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_finished",
field=models.DateField(blank=True, null=True),
),
]

View File

@ -1,39 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Edition = apps.get_model(
"games", "Edition"
) # Replace 'your_app_name' with the actual name of your app
for edition in Edition.objects.all():
name = edition.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
edition.sort_name = sort_name
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0023_purchase_date_finished"),
]
operations = [
migrations.AddField(
model_name="edition",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -1,39 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Game = apps.get_model(
"games", "Game"
) # Replace 'your_app_name' with the actual name of your app
for game in Game.objects.all():
name = game.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
game.sort_name = sort_name
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0024_edition_sort_name"),
]
operations = [
migrations.AddField(
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -1,266 +0,0 @@
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.time import format_duration
class Game(models.Model):
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self):
return self.filter(type=Purchase.GAME)
class Purchase(models.Model):
PHYSICAL = "ph"
DIGITAL = "di"
DIGITALUPGRADE = "du"
RENTED = "re"
BORROWED = "bo"
TRIAL = "tr"
DEMO = "de"
PIRATED = "pi"
OWNERSHIP_TYPES = [
(PHYSICAL, "Physical"),
(DIGITAL, "Digital"),
(DIGITALUPGRADE, "Digital Upgrade"),
(RENTED, "Rented"),
(BORROWED, "Borrowed"),
(TRIAL, "Trial"),
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "",
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs)
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class SessionQuerySet(models.QuerySet):
def total_duration_formatted(self):
return format_duration(self.total_duration_unformatted())
def total_duration_unformatted(self):
result = self.aggregate(
duration=Sum(F("duration_calculated") + F("duration_manual"))
)
return result["duration"]
class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey(
"Device",
on_delete=models.CASCADE,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager()
def __str__(self):
mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = timezone.now()
def start_now():
self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if self.is_manual():
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(), "%02.0H:%02.0m")
return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted()
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:
self.duration_calculated = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs)
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

View File

@ -1,24 +0,0 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_game",
source_value: "dataset.name",
target: "#id_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "textContent",
target: "#id_sort_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "dataset.year",
target: "#id_year_released",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");

View File

@ -1,12 +0,0 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_name",
source_value: "value",
target: "#id_sort_name",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");

View File

@ -1,47 +0,0 @@
import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenValueNotEqual,
} from "./utils.js";
let syncData = [
{
source: "#id_edition",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
disableElementsWhenValueNotEqual(
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => {
setupElementHandlers();
};
document.body.addEventListener("htmx:beforeRequest", function (event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_edition") {
var idEditionValue = document.getElementById("id_edition").value;
// Condition to check - replace this with your actual logic
if (idEditionValue != "") {
event.preventDefault(); // This cancels the HTMX request
}
}
});

View File

@ -1,23 +0,0 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,43 +0,0 @@
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != "string") dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
/**
* @param {Node} targetNode
*/
function addToggleButton(targetNode) {
let manualToggleButton = elt(
"td",
{},
elt(
"div",
{ className: "basic-button" },
elt(
"button",
{
onclick: (event) => {
let textInputField = elt("input", { type: "text", id: targetNode.id });
targetNode.replaceWith(textInputField);
event.target.addEventListener("click", (event) => {
textInputField.replaceWith(targetNode);
});
},
},
"Toggle manual"
)
)
);
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));
});

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +0,0 @@
<a href="{{ edit_url }}">
<button type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg">
<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>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,102 +0,0 @@
from django.urls import path
from games import views
urlpatterns = [
path("", views.index, name="index"),
path(
"list-sessions/recent",
views.list_sessions,
{"filter": "recent"},
name="list_sessions_recent",
),
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-for-purchase/<int:purchase_id>",
views.add_session,
name="add_session_for_purchase",
),
path(
"update-session/by-session/<int:session_id>",
views.update_session,
name="update_session",
),
path(
"start-session-same-as-last/<int:last_session_id>",
views.start_session_same_as_last,
name="start_session_same_as_last",
),
path(
"start-session/<int:game_id>",
views.start_game_session,
name="start_game_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-for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"),
path(
"add-edition-for-game/<int:game_id>",
views.add_edition,
name="add_edition_for_game",
),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
path("add-device/", views.add_device, name="add_device"),
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
path("list-sessions/", views.list_sessions, name="list_sessions"),
path(
"list-sessions/by-purchase/<int:purchase_id>",
views.list_sessions,
{"filter": "purchase"},
name="list_sessions_by_purchase",
),
path(
"list-sessions/by-platform/<int:platform_id>",
views.list_sessions,
{"filter": "platform"},
name="list_sessions_by_platform",
),
path(
"list-sessions/by-game/<int:game_id>",
views.list_sessions,
{"filter": "game"},
name="list_sessions_by_game",
),
path(
"list-sessions/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "edition"},
name="list_sessions_by_edition",
),
path(
"list-sessions/by-ownership/<str:ownership_type>",
views.list_sessions,
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
]

View File

@ -1,565 +0,0 @@
from datetime import datetime, timedelta
from typing import Any, Callable
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, Prefetch, Sum
from django.db.models.functions import TruncDate
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
from common.time import format_duration
from common.utils import safe_division
from .forms import (
DeviceForm,
EditionForm,
GameForm,
PlatformForm,
PurchaseForm,
SessionForm,
)
from .models import Edition, Game, Platform, Purchase, Session
def model_counts(request):
return {
"game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(),
}
def stats_dropdown_year_range(request):
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
return result
def add_session(request, purchase_id=None):
context = {}
initial = {"timestamp_start": timezone.now()}
last = Session.objects.last()
if last != None:
initial["purchase"] = last.purchase
if request.method == "POST":
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
}
)
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session"
context["form"] = form
return render(request, "add_session.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 use_custom_redirect(
func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]:
"""
Will redirect to "return_path" session variable if set.
"""
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = func(request, *args, **kwargs)
if isinstance(response, HttpResponseRedirect) and (
next_url := request.session.get("return_path")
):
return HttpResponseRedirect(next_url)
return response
return wrapper
@use_custom_redirect
def edit_session(request, session_id=None):
context = {}
session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None):
context = {}
purchase = Purchase.objects.get(id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@use_custom_redirect
def edit_game(request, game_id=None):
context = {}
purchase = Game.objects.get(id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
def view_game(request, game_id=None):
game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch = Prefetch(
"related_purchases",
queryset=Purchase.objects.exclude(type=Purchase.GAME),
to_attr="nongame_related_purchases",
)
game_purchases_prefetch = Prefetch(
"purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
sessions = Session.objects.filter(purchase__edition__game=game)
session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
}
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None):
context = {}
purchase = Platform.objects.get(id=platform_id)
form = PlatformForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None):
context = {}
edition = Edition.objects.get(id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Edition"
context["form"] = form
return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
@use_custom_redirect
def start_game_session(request, game_id: int):
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions")
def start_session_same_as_last(request, last_session_id: int):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
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,
filter="",
purchase_id="",
platform_id="",
game_id="",
edition_id="",
ownership_type: str = "",
):
context = {}
context["title"] = "Sessions"
if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game":
dataset = Session.objects.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type":
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
current_year = timezone.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start")
context["title"] = "This year"
else:
# by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start")
for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0
):
session.timestamp_end = timezone.now()
session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset
try:
context["last"] = Session.objects.latest()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context)
def stats(request, year: int = 0):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = timezone.now().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.filter(
price_currency__exact=selected_currency
)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
date_finished__isnull=True
).filter(
type=Purchase.GAME
) # do not count DLC etc.
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
)
* 100
)
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished"
)
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.intersection(
purchases_finished_this_year
).order_by("date_finished")
)
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year)
.intersection(purchases_finished_this_year)
.count()
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
edition__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded.count())
),
"all_finished_this_year": purchases_finished_this_year.order_by(
"date_finished"
),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"date_finished"
),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"refunded_percent": int(
safe_division(
this_year_purchases_refunded.count(),
this_year_purchases_with_currency.count(),
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
"date_purchased"
),
"backlog_decrease_count": backlog_decrease_count,
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
def add_purchase(request, edition_id=None):
context = {}
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
purchase = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
)
)
else:
return redirect("index")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
}
)
else:
form = PurchaseForm(initial=initial)
context["form"] = form
context["title"] = "Add New Purchase"
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
def add_game(request):
context = {}
form = GameForm(request.POST or None)
if form.is_valid():
game = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("index")
context["form"] = form
context["title"] = "Add New Game"
context["script_name"] = "add_game.js"
return render(request, "add_game.html", context)
def add_edition(request, game_id=None):
context = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = Game.objects.get(id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.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 add_device(request):
context = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
def index(request):
return redirect("list_sessions_recent")

View File

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

794
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,14 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.1" version = "0.1.0"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.10"
django = "^4.2.0" django = "^4.1.4"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
@ -22,15 +21,12 @@ django-extensions = "^3.2.1"
werkzeug = "^2.2.2" werkzeug = "^2.2.2"
djhtml = "^1.5.2" djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4"
pre-commit = "^3.5.0"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts] [tool.pytest.ini_options]
timetracker-import = "common.import_data:import_from_file" pythonpath = [
"src"
]

22
src/input.css Normal file
View File

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

View File

@ -0,0 +1,3 @@
import logging
logging.basicConfig(level=logging.ERROR, filename="timelogger.log")

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

2
manage.py → src/web/manage.py Executable file → Normal file
View File

@ -6,7 +6,7 @@ import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:

View File

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

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