Compare commits

..

1 Commits

Author SHA1 Message Date
lukas 0c20d31d31 Initial commit
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-11-11 23:29:07 +01:00
135 changed files with 7070 additions and 7630 deletions
+13 -27
View File
@@ -9,42 +9,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v4
with:
enable-cache: false
python-version: "3.14"
- name: Install dependencies
run: uv sync --frozen
- name: Run Migrations
run: uv run python manage.py migrate
# - name: Run Tests
# run: PROD=1 uv run pytest
python-version: 3.12
- run: |
python -m pip install poetry
poetry install
poetry env info
poetry run python manage.py migrate
# PROD=1 poetry run pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set Version
run: echo "VERSION_NUMBER=1.6.1" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
- 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 }}
# cache-from: type=gha
# cache-to: type=gha,mode=max
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1
+20
View File
@@ -0,0 +1,20 @@
repos:
# disable due to incomaptible formatting between
# black and ruff
# TODO: replace with ruff when it works on NixOS
# - repo: https://github.com/psf/black
# rev: 24.8.0
# hooks:
# - id: black
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
hooks:
- id: djlint-reformat-django
args: ["--ignore", "H011"]
- id: djlint-django
args: ["--ignore", "H011"]
-26
View File
@@ -1,26 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
},
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"args": [
"runserver"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/manage.py"
}
]
}
+4 -15
View File
@@ -1,15 +1,6 @@
## 1.6.1 / 2026-01-30 11:48+01:00
## Unreleased
### New
* Pre-fill time played into new playevent, also tracks time since last playevent
* Improve light theme and fix light/dark theme switcher
* Fix purchase form logic
* Update dependencies
## 1.6.0 / 2025-01-15 23:13+01:00
### New
* Visual overhaul of many pages
## New
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
@@ -17,10 +8,8 @@
* Add all-time stats
* Manage purchases
* Automatically convert purchase prices
* Add emulated property to sessions
* Add today's and last 7 days playtime stats to navbar
### Improved
## Improved
* mark refunded purchases red on game overview
* increase session count on game overview when starting a new session
* game overview:
@@ -31,7 +20,7 @@
* session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover
### Fixed
## Fixed
* Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions
+34 -30
View File
@@ -1,41 +1,45 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
FROM python:3.12.0-slim-bullseye
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /home/timetracker/app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM python:3.14-slim-bookworm
ENV PROD=1 \
ENV VERSION_NUMBER=1.5.2 \
PROD=1 \
PYTHONUNBUFFERED=1 \
PATH="/home/timetracker/app/.venv/bin:$PATH"
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 \
&& apt-get install --no-install-recommends -y \
bash \
curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \
&& poetry --version \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker \
&& mkdir -p /var/www/django/static \
&& chown timetracker:timetracker /var/www/django/static
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
WORKDIR /home/timetracker/app
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
COPY --chown=timetracker:timetracker entrypoint.sh /
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app
COPY 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
ENV VERSION_NUMBER=1.6.1
EXPOSE 8000
CMD [ "/entrypoint.sh" ]
+22 -26
View File
@@ -9,68 +9,64 @@ npm:
npm install
css: common/input.css
npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
makemigrations:
uv run python manage.py makemigrations
poetry run python manage.py makemigrations
migrate: makemigrations
uv run python manage.py migrate
poetry run python manage.py migrate
init:
uv install $(PYTHON_VERSION)
uv sync
pyenv install -s $(PYTHON_VERSION)
pyenv local $(PYTHON_VERSION)
pip install poetry
poetry install
npm install
$(MAKE) sethookdir
$(MAKE) loadplatforms
sethookdir:
git config core.hooksPath .githooks
chmod +x .githooks/*
dev:
@npx concurrently \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"uv run python -Wa manage.py runserver" \
"npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
"poetry run python -Wa manage.py runserver" \
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
caddy:
caddy run --watch
dev-prod: migrate collectstatic
PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
dumpgames:
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
loadplatforms:
uv run python manage.py loaddata platforms.yaml
poetry run python manage.py loaddata platforms.yaml
loadall:
uv run python manage.py loaddata data.yaml
poetry run python manage.py loaddata data.yaml
loadsample:
uv run python manage.py loaddata sample.yaml
poetry run python manage.py loaddata sample.yaml
createsuperuser:
uv run python manage.py createsuperuser
poetry run python manage.py createsuperuser
shell:
uv run python manage.py shell
poetry run python manage.py shell
collectstatic:
uv run python manage.py collectstatic --clear --no-input
poetry run python manage.py collectstatic --clear --no-input
uv.lock: pyproject.toml
uv sync
poetry.lock: pyproject.toml
poetry install
test: uv.lock
uv run pytest
test: poetry.lock
poetry run pytest
date:
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
cleanstatic:
rm -r static/*
+114 -125
View File
@@ -3,13 +3,11 @@ from string import ascii_lowercase
from typing import Any, Callable
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate
from games.models import Game, Purchase, Session
HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str
@@ -32,7 +30,7 @@ def Component(
attributesList = [f'{name}="{value}"' for name, value in attributes]
# make attribute list into a string
# and insert space between tag and attribute list
attributesBlob = f" {' '.join(attributesList)}"
attributesBlob = f" {" ".join(attributesList)}"
tag: str = ""
if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
@@ -52,7 +50,6 @@ def randomid(seed: str = "", length: int = 10) -> str:
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
@@ -65,43 +62,17 @@ def Popover(
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
],
children=children,
template="cotton/popover.html",
)
def PopoverTruncated(
input_string: str,
popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
The truncated text ends in `ellipsis`, and optionally
an always-visible `endpart` can be specified.
`popover_content` can be specified if:
1. It needs to be always displayed regardless if text is truncated.
2. It needs to differ from `input_string`.
"""
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
return Popover(
wrapped_content=truncated,
popover_content=popover_content if popover_content else input_string,
)
def PopoverTruncated(input_string: str) -> str:
if (truncated := truncate(input_string)) != input_string:
return Popover(wrapped_content=truncated, popover_content=input_string)
else:
if popover_content and popover_if_not_truncated:
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
else:
return input_string
return input_string
def A(
@@ -142,13 +113,7 @@ def Button(
):
return Component(
template="cotton/button.html",
attributes=attributes
+ [
("size", size),
("icon", icon),
("color", color),
("class", "hover:cursor-pointer"),
],
attributes=attributes + [("size", size), ("icon", icon), ("color", color)],
children=children,
)
@@ -160,14 +125,33 @@ def Div(
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
def Label(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
return Component(tag_name="label", attributes=attributes, children=children)
def Input(
type: str = "text",
label: str = "",
id: str = "",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
input_component = Component(
tag_name="input",
attributes=attributes + [("type", type), ("id", id)],
children=children,
)
if label != "":
if id == "":
raise ValueError("Label is set but element ID is missing.")
return Label(
attributes=[("for", id)], children=[label, input_component, *children]
)
else:
return input_component
def Form(
@@ -183,6 +167,74 @@ def Form(
)
def Fieldset(
label: str = "",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
if label != "":
children = [Label(children=[label, *children])]
return Component(tag_name="fieldset", attributes=attributes, children=children)
def RadioFieldset(name: str, label: str, radio_buttons: list[dict[str, str]]):
return Component(
tag_name="span",
children=[
Component(tag_name="legend", children=label),
Component(
tag_name="fieldset",
children=[
Component(
tag_name="label",
attributes=[
("for", f"{name}__{radio["value"]}"),
],
children=[
radio["label"],
Input(
type="radio",
attributes=[
("id", f"{name}__{radio["value"]}"),
("name", name),
("value", radio["value"]),
("onClick", radio.get("onclick", "")),
],
),
],
)
for radio in radio_buttons
],
),
],
)
def BooleanRadioFieldset(name: str, label: str):
return RadioFieldset(
name=name,
label=label,
radio_buttons=[
{"label": "True", "value": "true"},
{"label": "False", "value": "false"},
],
)
def SubmitButton(label: str):
return Input(type="submit", attributes=[("value", label)])
# RadioFieldset(
# name="filter__dropped",
# label="Dropped",
# radio_buttons=[
# {"label": "True", "value": "true"},
# {"label": "False", "value": "false"},
# ],
# )
def Icon(
name: str,
attributes: list[HTMLAttribute] = [],
@@ -194,83 +246,15 @@ def Icon(
return result
def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
game_count = purchase.games.count()
popover_if_not_truncated = False
if game_count == 1:
link_content += purchase.games.first().name
popover_content = link_content
if game_count > 1:
if purchase.name:
link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{game_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul>
"""
icon = purchase.platform.icon if game_count == 1 else "unspecified"
if link_content == "":
raise ValueError("link_content is empty!!")
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
link = reverse("view_game", args=[int(game_id)])
a_content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
icon,
[("title", "Multiple")],
),
PopoverTruncated(
input_string=link_content,
popover_content=mark_safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated,
),
],
)
return mark_safe(A(url=link, children=[a_content]))
def NameWithIcon(
name: str = "",
platform: str = "",
game_id: int = 0,
session_id: int = 0,
purchase_id: int = 0,
linkify: bool = True,
emulated: bool = False,
) -> SafeText:
create_link = False
link = ""
platform = None
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
create_link = True
if session_id:
session = Session.objects.get(pk=session_id)
emulated = session.emulated
game_id = session.game.pk
if purchase_id:
purchase = Purchase.objects.get(pk=purchase_id)
game_id = purchase.games.first().pk
if game_id:
game = Game.objects.get(pk=game_id)
name = name or game.name
platform = game.platform
link = reverse("view_game", args=[int(game_id)])
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
)
if platform
else "",
Icon("emulated", [("title", "Emulated")]) if emulated else "",
),
PopoverTruncated(name),
],
)
@@ -278,16 +262,21 @@ def NameWithIcon(
return mark_safe(
A(
url=link,
children=[content],
)
if create_link
else content,
children=[a_content],
),
)
def PurchasePrice(purchase) -> str:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
),
PopoverTruncated(name),
],
)
return mark_safe(content)
+74 -143
View File
@@ -1,171 +1,126 @@
@import 'tailwindcss';
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
@plugin 'flowbite/plugin';
@source '../node_modules/flowbite/**/*.js';
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono:
IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--font-serif:
IBM Plex Serif, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-condensed:
IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-accent: #7c3aed;
--color-background: #1f2937;
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@utility min-w-20char {
min-width: 20ch;
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@utility max-w-20char {
max-width: 20ch;
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@utility min-w-30char {
min-width: 30ch;
@font-face {
font-family: "IBM Plex Sans Condensed";
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@utility max-w-30char {
max-width: 30ch;
}
@utility max-w-35char {
max-width: 35ch;
}
@utility max-w-40char {
max-width: 40ch;
}
@layer utilities {
@font-face {
font-family: 'IBM Plex Mono';
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
/* a:hover {
/* a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
} */
/* form label {
form label {
@apply dark:text-slate-400;
} */
}
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
}
.responsive-table tr:nth-child(even) {
@apply bg-slate-800
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
@layer utilities {
.min-w-20char {
min-width: 20ch;
}
.responsive-table tr:nth-child(even) {
@apply bg-indigo-100 dark:bg-slate-800;
.max-w-20char {
max-width: 20ch;
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-indigo-200 dark:bg-slate-900;
.min-w-30char {
min-width: 30ch;
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
.max-w-30char {
max-width: 30ch;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
.max-w-35char {
max-width: 35ch;
}
.max-w-40char {
max-width: 40ch;
}
}
/* form input,
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} */
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed;
@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) {
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
} */
}
/* @media screen and (max-width: 768px) {
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} */
}
#button-container button {
@apply mx-1;
@@ -176,7 +131,7 @@ textarea:disabled {
}
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded-sm shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-hidden focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}
.markdown-content ul {
@@ -214,27 +169,3 @@ textarea:disabled {
}
} */
label {
@apply dark:text-slate-500;
}
[type="text"], [type="password"], [type="datetime-local"], [type="datetime"], [type="date"], [type="number"], select, textarea {
@apply dark:bg-slate-600 dark:text-slate-300;
}
[type="submit"] {
@apply dark:text-white font-bold dark:bg-blue-600 px-4 py-2;
}
form div label {
@apply dark:text-white;
}
form div {
@apply flex flex-col;
}
div [type="submit"] {
@apply mt-3;
}
+1 -1
View File
@@ -13,7 +13,7 @@ durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None):
if duration is None:
if duration == None:
return timedelta(0)
elif isinstance(duration, int):
return timedelta(seconds=duration)
+14 -98
View File
@@ -1,13 +1,8 @@
import operator
from dataclasses import dataclass
from datetime import date
from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from typing import Any, Generator, TypeVar
from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
from django.apps import apps
from django.db.models import Model
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
@@ -42,31 +37,14 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
return obj
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
def truncate(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > 30
else input_string
)
def truncate(
input_string: str, length: int = 30, ellipsis: str = "", endpart: str = ""
) -> str:
max_content_length = length - len(endpart)
if max_content_length < 0:
raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length:
return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return (
f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
)
T = TypeVar("T", str, int, date)
@@ -91,77 +69,15 @@ def format_float_or_int(number: int | float):
return int(number) if float(number).is_integer() else f"{number:03.2f}"
OperatorType = Literal["|", "&"]
def get_model_by_string(app_label: str, model_name: str):
return apps.get_model(app_label, model_name)
@dataclass
class FilterEntry:
condition: Q
operator: OperatorType = "&"
def get_field(model: Model, field_name: str):
field = model._meta.get_field(field_name)
return field
def build_dynamic_filter(
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
):
"""
Constructs a Django Q filter from a list of filter conditions.
Args:
filters (list): A list where each item is either:
- A Q object (default AND logic applied)
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
Returns:
Q: A combined Q object that can be passed to Django's filter().
"""
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
"|": operator.or_,
"&": operator.and_,
}
# Convert all plain Q objects into (Q, "&") for default AND behavior
processed_filters = [
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
]
# Reduce with dynamic operators
return reduce(
lambda combined_filters, filter: op_map[filter.operator](
combined_filters, filter.condition
),
processed_filters,
Q(),
)
def redirect_to(default_view: str, *default_args):
"""
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
:param default_view: The name of the default view to redirect to if 'next' is missing.
:param default_args: Any arguments required for the default view.
"""
def decorator(view_func):
@wraps(view_func)
def wrapped_view(request: HttpRequest, *args, **kwargs):
next_url = request.GET.get("next")
if not next_url:
from django.urls import (
reverse, # Import inside function to avoid circular imports
)
next_url = reverse(default_view, args=default_args)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
return redirect(next_url)
return wrapped_view
return decorator
def add_next_param_to_url(url: str, nexturl: str) -> str:
return f"{url}?{urlencode({'next': nexturl})}"
def get_field_type(model: Model, field_name: str):
field = model._meta.get_field(field_name)
return type(field)
@@ -1,33 +0,0 @@
from datetime import datetime
import requests
url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json"
date_format = "%Y%m%d"
years = range(2000, datetime.now().year + 1)
dates = [
datetime.strftime(datetime(day=1, month=1, year=year), format=date_format)
for year in years
]
for date in dates:
final_url = url.format(date)
year = date[:4]
response = requests.get(final_url)
response.raise_for_status()
data = response.json()
if kurzy := data.get("kurzy"):
with open("output.yaml", mode="a") as o:
rates = [
f"""
- model: games.exchangerate
fields:
currency_from: {currency_name}
currency_to: CZK
year: {year}
rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)}
"""
for currency_name in ["EUR", "USD", "CNY"]
if kurzy.get(currency_name)
]
o.writelines(rates)
# time.sleep(0.5)
-65
View File
@@ -1,65 +0,0 @@
import sys
import yaml
def load_yaml(filename):
with open(filename, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or []
def save_yaml(filename, data):
with open(filename, "w", encoding="utf-8") as file:
yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False)
def extract_existing_combinations(data):
return {
(
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
for entry in data
if entry["model"] == "games.exchangerate"
}
def filter_new_entries(existing_combinations, additional_files):
new_entries = []
for filename in additional_files:
data = load_yaml(filename)
for entry in data:
if entry["model"] == "games.exchangerate":
key = (
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
if key not in existing_combinations:
new_entries.append(entry)
return new_entries
def main():
if len(sys.argv) < 3:
print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]")
sys.exit(1)
example_file = sys.argv[1]
additional_files = sys.argv[2:]
output_file = "filtered_output.yaml"
existing_data = load_yaml(example_file)
existing_combinations = extract_existing_combinations(existing_data)
new_entries = filter_new_entries(existing_combinations, additional_files)
save_yaml(output_file, new_entries)
print(f"Filtered data saved to {output_file}")
if __name__ == "__main__":
main()
+4 -4
View File
@@ -2,10 +2,10 @@
# Apply database migrations
set -euo pipefail
echo "Apply database migrations"
python manage.py migrate
poetry run python manage.py migrate
echo "Collect static files"
python manage.py collectstatic --clear --no-input
poetry run python manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
@@ -15,9 +15,9 @@ _term() {
trap _term SIGTERM
echo "Starting Django-Q cluster"
python manage.py qcluster & django_q_pid=$!
poetry run python manage.py qcluster & django_q_pid=$!
echo "Starting app"
python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid" "$django_q_pid"
+2
View File
@@ -2,6 +2,7 @@ from django.contrib import admin
from games.models import (
Device,
Edition,
ExchangeRate,
Game,
Platform,
@@ -14,5 +15,6 @@ admin.site.register(Game)
admin.site.register(Purchase)
admin.site.register(Platform)
admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device)
admin.site.register(ExchangeRate)
-95
View File
@@ -1,95 +0,0 @@
from datetime import date, datetime
from typing import List
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
from games.models import Game, PlayEvent
api = NinjaAPI()
playevent_router = Router()
game_router = Router()
NOW_FACTORY = django_timezone_now
class GameStatusUpdate(Schema):
status: str
class PlayEventIn(Schema):
game_id: int
started: date | None = None
ended: date | None = None
note: str = ""
days_to_finish: int | None = None
class AutoPlayEventIn(ModelSchema):
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note"]
class UpdatePlayEventIn(Schema):
started: date | None = None
ended: date | None = None
note: str = ""
class PlayEventOut(Schema):
id: int
game: str = Field(..., alias="game.name")
started: date | None = None
ended: date | None = None
days_to_finish: int | None = None
note: str = ""
updated_at: datetime
created_at: datetime
@game_router.patch("/{game_id}/status", response={204: None})
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
game = get_object_or_404(Game, id=game_id)
setattr(game, "status", payload.status)
game.save()
return 204, None
@playevent_router.get("/", response=List[PlayEventOut])
def list_playevents(request):
return PlayEvent.objects.all()
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
return playevent
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
def get_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
return playevent
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(playevent, attr, value)
playevent.save()
return playevent
@playevent_router.delete("/{playevent_id}", response={204: None})
def delete_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return 204, None
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router)
+11 -24
View File
@@ -1,10 +1,9 @@
# from datetime import timedelta
from datetime import timedelta
from django.apps import AppConfig
from django.core.management import call_command
from django.db.models.signals import post_migrate
# from django.utils.timezone import now
from django.utils.timezone import now
class GamesConfig(AppConfig):
@@ -12,32 +11,20 @@ class GamesConfig(AppConfig):
name = "games"
def ready(self):
import games.signals # noqa: F401
post_migrate.connect(schedule_tasks, sender=self)
def schedule_tasks(sender, **kwargs):
# from django_q.models import Schedule
# from django_q.tasks import schedule
from django_q.models import Schedule
from django_q.tasks import schedule
# if not Schedule.objects.filter(name="Update converted prices").exists():
# schedule(
# "games.tasks.convert_prices",
# name="Update converted prices",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
# if not Schedule.objects.filter(name="Update price per game").exists():
# schedule(
# "games.tasks.calculate_price_per_game",
# name="Update price per game",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
if not Schedule.objects.filter(name="Update converted prices").exists():
schedule(
"games.tasks.convert_prices",
name="Update converted prices",
schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30),
)
from games.models import ExchangeRate
+101
View File
@@ -0,0 +1,101 @@
from enum import Enum
from typing import Any
from django.db.models import (
BooleanField,
CharField,
FloatField,
IntegerField,
QuerySet,
TextField,
)
from django.http import HttpRequest
from common.components import *
from common.utils import get_field, get_model_by_string
filter_param_prefix = "f_"
class Modifier(Enum):
EQUALS = "__exact"
GT = "__gt"
LT = "__lt"
CONTAINS = "__contains"
REGEX = "__regex"
ISNULL = "__isnull"
BETWEEN = "__gt", "__lt"
def create_filter_form(model: str, fields: list[str]):
filter_model = get_model_by_string("games", model)
automatic_filter_form_parts = []
for field in fields:
html_field_name = f"{filter_param_prefix}{field}"
match get_field(filter_model, field):
case BooleanField():
automatic_filter_form_parts.append(
BooleanRadioFieldset(name=html_field_name, label=field)
)
case TextField():
pass
case CharField():
js = str
onclick_handler: js = """f_price_currency.disabled = true;"""
automatic_filter_form_parts.extend(
[
RadioFieldset(
name=f"{field}_switch",
label="Modifier",
radio_buttons=[
{
"label": "Equals",
"value": Modifier.EQUALS.value,
"onclick": onclick_handler,
},
{"label": "Contains", "value": Modifier.CONTAINS.value},
],
),
Input(
label=field,
id=html_field_name,
attributes=[
("name", html_field_name + str(Modifier.EQUALS.value))
],
),
]
)
case IntegerField():
pass
case FloatField():
html = Input(
label=field,
type="number",
id=html_field_name,
attributes=[("name", html_field_name)],
)
automatic_filter_form_parts.append(html)
case _:
print(f"Field type of {field} not handled yet.")
automatic_filter_form = Form(
children=[*automatic_filter_form_parts, SubmitButton("Apply")]
)
return automatic_filter_form
def apply_filters(request: HttpRequest, queryset: QuerySet[Any]):
for parameter in request.GET:
if parameter.startswith(filter_param_prefix):
field_name = parameter.removeprefix(filter_param_prefix)
field_value = request.GET.get(parameter)
if field_value == "":
continue
match field_value:
case "true":
field_value = True
case "false":
field_value = False
case _:
pass
queryset = queryset.filter(**{f"{field_name}": field_value})
return queryset
-392
View File
@@ -110,395 +110,3 @@
currency_to: CZK
year: 2018
rate: 3.268
- model: games.exchangerate
pk: 17
fields:
currency_from: CNY
currency_to: CZK
year: 2023
rate: 3.281
- model: games.exchangerate
pk: 18
fields:
currency_from: EUR
currency_to: CZK
year: 2009
rate: 26.445
- model: games.exchangerate
pk: 19
fields:
currency_from: CNY
currency_to: CZK
year: 2025
rate: 3.35
- model: games.exchangerate
pk: 20
fields:
currency_from: EUR
currency_to: CZK
year: 2016
rate: 27.033
- model: games.exchangerate
pk: 21
fields:
currency_from: EUR
currency_to: CZK
year: 2025
rate: 25.2021966
- model: games.exchangerate
pk: 22
fields:
currency_from: EUR
currency_to: CZK
year: 2017
rate: 26.33
- model: games.exchangerate
pk: 23
fields:
currency_from: EUR
currency_to: CZK
year: 2000
rate: 36.13
- model: games.exchangerate
pk: 24
fields:
currency_from: USD
currency_to: CZK
year: 2000
rate: 35.979
- model: games.exchangerate
pk: 25
fields:
currency_from: EUR
currency_to: CZK
year: 2001
rate: 35.09
- model: games.exchangerate
pk: 26
fields:
currency_from: USD
currency_to: CZK
year: 2001
rate: 37.813
- model: games.exchangerate
pk: 27
fields:
currency_from: EUR
currency_to: CZK
year: 2002
rate: 31.98
- model: games.exchangerate
pk: 28
fields:
currency_from: USD
currency_to: CZK
year: 2002
rate: 36.259
- model: games.exchangerate
pk: 29
fields:
currency_from: EUR
currency_to: CZK
year: 2003
rate: 31.6
- model: games.exchangerate
pk: 30
fields:
currency_from: USD
currency_to: CZK
year: 2003
rate: 30.141
- model: games.exchangerate
pk: 31
fields:
currency_from: EUR
currency_to: CZK
year: 2004
rate: 32.405
- model: games.exchangerate
pk: 32
fields:
currency_from: USD
currency_to: CZK
year: 2004
rate: 25.654
- model: games.exchangerate
pk: 33
fields:
currency_from: EUR
currency_to: CZK
year: 2005
rate: 30.465
- model: games.exchangerate
pk: 34
fields:
currency_from: USD
currency_to: CZK
year: 2005
rate: 22.365
- model: games.exchangerate
pk: 35
fields:
currency_from: EUR
currency_to: CZK
year: 2006
rate: 29.005
- model: games.exchangerate
pk: 36
fields:
currency_from: USD
currency_to: CZK
year: 2006
rate: 24.588
- model: games.exchangerate
pk: 37
fields:
currency_from: CNY
currency_to: CZK
year: 2006
rate: 3.047
- model: games.exchangerate
pk: 38
fields:
currency_from: EUR
currency_to: CZK
year: 2007
rate: 27.495
- model: games.exchangerate
pk: 39
fields:
currency_from: USD
currency_to: CZK
year: 2007
rate: 20.876
- model: games.exchangerate
pk: 40
fields:
currency_from: CNY
currency_to: CZK
year: 2007
rate: 2.674
- model: games.exchangerate
pk: 41
fields:
currency_from: EUR
currency_to: CZK
year: 2008
rate: 26.62
- model: games.exchangerate
pk: 42
fields:
currency_from: USD
currency_to: CZK
year: 2008
rate: 18.078
- model: games.exchangerate
pk: 43
fields:
currency_from: CNY
currency_to: CZK
year: 2008
rate: 2.475
- model: games.exchangerate
pk: 44
fields:
currency_from: USD
currency_to: CZK
year: 2009
rate: 19.346
- model: games.exchangerate
pk: 45
fields:
currency_from: CNY
currency_to: CZK
year: 2009
rate: 2.836
- model: games.exchangerate
pk: 46
fields:
currency_from: USD
currency_to: CZK
year: 2010
rate: 18.368
- model: games.exchangerate
pk: 47
fields:
currency_from: CNY
currency_to: CZK
year: 2010
rate: 2.691
- model: games.exchangerate
pk: 48
fields:
currency_from: EUR
currency_to: CZK
year: 2011
rate: 25.06
- model: games.exchangerate
pk: 49
fields:
currency_from: USD
currency_to: CZK
year: 2011
rate: 18.751
- model: games.exchangerate
pk: 50
fields:
currency_from: CNY
currency_to: CZK
year: 2011
rate: 2.845
- model: games.exchangerate
pk: 51
fields:
currency_from: USD
currency_to: CZK
year: 2012
rate: 19.94
- model: games.exchangerate
pk: 52
fields:
currency_from: CNY
currency_to: CZK
year: 2012
rate: 3.168
- model: games.exchangerate
pk: 53
fields:
currency_from: EUR
currency_to: CZK
year: 2013
rate: 25.14
- model: games.exchangerate
pk: 54
fields:
currency_from: CNY
currency_to: CZK
year: 2013
rate: 3.059
- model: games.exchangerate
pk: 55
fields:
currency_from: USD
currency_to: CZK
year: 2014
rate: 19.894
- model: games.exchangerate
pk: 56
fields:
currency_from: CNY
currency_to: CZK
year: 2014
rate: 3.286
- model: games.exchangerate
pk: 57
fields:
currency_from: EUR
currency_to: CZK
year: 2015
rate: 27.725
- model: games.exchangerate
pk: 58
fields:
currency_from: USD
currency_to: CZK
year: 2015
rate: 22.834
- model: games.exchangerate
pk: 59
fields:
currency_from: USD
currency_to: CZK
year: 2016
rate: 24.824
- model: games.exchangerate
pk: 60
fields:
currency_from: CNY
currency_to: CZK
year: 2017
rate: 3.693
- model: games.exchangerate
pk: 61
fields:
currency_from: EUR
currency_to: CZK
year: 2018
rate: 25.54
- model: games.exchangerate
pk: 62
fields:
currency_from: USD
currency_to: CZK
year: 2018
rate: 21.291
- model: games.exchangerate
pk: 63
fields:
currency_from: EUR
currency_to: CZK
year: 2019
rate: 25.725
- model: games.exchangerate
pk: 64
fields:
currency_from: EUR
currency_to: CZK
year: 2020
rate: 25.41
- model: games.exchangerate
pk: 65
fields:
currency_from: USD
currency_to: CZK
year: 2020
rate: 22.621
- model: games.exchangerate
pk: 66
fields:
currency_from: EUR
currency_to: CZK
year: 2021
rate: 26.245
- model: games.exchangerate
pk: 67
fields:
currency_from: USD
currency_to: CZK
year: 2021
rate: 21.387
- model: games.exchangerate
pk: 68
fields:
currency_from: CNY
currency_to: CZK
year: 2021
rate: 3.273
- model: games.exchangerate
pk: 69
fields:
currency_from: USD
currency_to: CZK
year: 2022
rate: 21.951
- model: games.exchangerate
pk: 70
fields:
currency_from: CNY
currency_to: CZK
year: 2022
rate: 3.458
- model: games.exchangerate
pk: 71
fields:
currency_from: EUR
currency_to: CZK
year: 2023
rate: 24.115
- model: games.exchangerate
pk: 72
fields:
currency_from: USD
currency_to: CZK
year: 2025
rate: 24.237
+38 -126
View File
@@ -1,17 +1,8 @@
from django import forms
from django.db import transaction
from django.urls import reverse
from common.utils import safe_getattr
from games.models import (
Device,
Game,
GameStatusChange,
Platform,
PlayEvent,
Purchase,
Session,
)
from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
@@ -20,37 +11,17 @@ custom_datetime_widget = forms.DateTimeInput(
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SingleGameChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
# purchase = forms.ModelChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
duration_manual = forms.DurationField(
required=False,
widget=forms.TextInput(
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
mark_as_played = forms.BooleanField(
required=False,
initial={"mark_as_played": True},
label="Set game status to Played if Unplayed",
)
class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
@@ -58,30 +29,21 @@ class SessionForm(forms.ModelForm):
}
model = Session
fields = [
"game",
"purchase",
"timestamp_start",
"timestamp_end",
"duration_manual",
"emulated",
"device",
"note",
"mark_as_played",
]
def save(self, commit=True):
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_played"):
game_instance = session.game
if game_instance.status == "u":
game_instance.status = "p"
if commit:
game_instance.save()
if commit:
session.save()
return session
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.SelectMultiple):
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"):
@@ -94,50 +56,44 @@ class PurchaseForm(forms.ModelForm):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected game.
related_purchase_by_game_url = reverse("related_purchase_by_game")
self.fields["games"].widget.attrs.update(
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_game_url,
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME),
required=False,
)
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
label="Currency",
required=False,
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
}
model = Purchase
fields = [
"games",
"edition",
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"date_dropped",
"infinite",
"price",
"price_currency",
@@ -184,23 +140,24 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name
class GameForm(forms.ModelForm):
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = [
"name",
"sort_name",
"platform",
"original_year_released",
"year_released",
"status",
"mastered",
"wikidata",
]
fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}
@@ -220,48 +177,3 @@ class DeviceForm(forms.ModelForm):
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
mark_as_finished = forms.BooleanField(
required=False,
initial={"mark_as_finished": True},
label="Set game status to Finished",
)
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note", "mark_as_finished"]
widgets = {
"started": custom_date_widget,
"ended": custom_date_widget,
}
def save(self, commit=True):
with transaction.atomic():
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_finished"):
game_instance = session.game
game_instance.status = "f"
game_instance.save()
session.save()
return session
class GameStatusChangeForm(forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
"game",
"old_status",
"new_status",
"timestamp",
]
widgets = {
"timestamp": custom_datetime_widget,
}
+1
View File
@@ -1,4 +1,5 @@
from .device import Query as DeviceQuery
from .edition import Query as EditionQuery
from .game import Query as GameQuery
from .platform import Query as PlatformQuery
from .purchase import Query as PurchaseQuery
+11
View File
@@ -0,0 +1,11 @@
import graphene
from games.graphql.types import Edition
from games.models import Game as EditionModel
class Query(graphene.ObjectType):
editions = graphene.List(Edition)
def resolve_editions(self, info, **kwargs):
return EditionModel.objects.all()
+73 -76
View File
@@ -1,6 +1,5 @@
# Generated by Django 5.1.5 on 2025-01-29 21:26
# Generated by Django 4.1.4 on 2023-01-02 18:27
import datetime
import django.db.models.deletion
from django.db import migrations, models
@@ -9,96 +8,94 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Device',
name="Game",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("wikidata", models.CharField(max_length=50)),
],
),
migrations.CreateModel(
name='Platform',
name="Platform",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
('icon', models.SlugField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("group", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='ExchangeRate',
name="Purchase",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
},
),
migrations.CreateModel(
name='Game',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
('year_released', models.IntegerField(blank=True, default=None, null=True)),
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
],
options={
'unique_together': {('name', 'platform', 'year_released')},
},
),
migrations.CreateModel(
name='Purchase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_purchased', models.DateField()),
('date_refunded', models.DateField(blank=True, null=True)),
('date_finished', models.DateField(blank=True, null=True)),
('date_dropped', models.DateField(blank=True, null=True)),
('infinite', models.BooleanField(default=False)),
('price', models.FloatField(default=0)),
('price_currency', models.CharField(default='USD', max_length=3)),
('converted_price', models.FloatField(null=True)),
('converted_currency', models.CharField(max_length=3, null=True)),
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date_purchased", models.DateField()),
("date_refunded", models.DateField(blank=True, null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
],
),
migrations.CreateModel(
name='Session',
name="Session",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp_start', models.DateTimeField()),
('timestamp_end', models.DateTimeField(blank=True, null=True)),
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
('duration_calculated', models.DurationField(blank=True, null=True)),
('note', models.TextField(blank=True, null=True)),
('emulated', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timestamp_start", models.DateTimeField()),
("timestamp_end", models.DateTimeField()),
("duration_manual", models.DurationField(blank=True, null=True)),
("duration_calculated", models.DurationField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
(
"purchase",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.purchase",
),
),
],
options={
'get_latest_by': 'timestamp_start',
},
),
]
@@ -0,0 +1,22 @@
# Generated by Django 4.1.4 on 2023-01-02 18:55
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.FloatField(null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.1.4 on 2023-01-02 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0002_alter_session_duration_manual"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(blank=True, null=True),
),
migrations.AlterField(
model_name="session",
name="timestamp_end",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0002_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 4.1.5 on 2023-01-09 14:49
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0003_alter_session_duration_manual_and_more"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]
@@ -1,28 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:57
from django.db import migrations, models
from django.db.models import Count
def initialize_num_purchases(apps, schema_editor):
Purchase = apps.get_model("games", "Purchase")
purchases = Purchase.objects.annotate(num_games=Count("games"))
for purchase in purchases:
purchase.num_purchases = purchase.num_games
purchase.save(update_fields=["num_purchases"])
class Migration(migrations.Migration):
dependencies = [
("games", "0003_purchase_updated_at"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="num_purchases",
field=models.IntegerField(default=0),
),
migrations.RunPython(initialize_num_purchases),
]
@@ -0,0 +1,35 @@
# Generated by Django 4.1.5 on 2023-01-09 17:43
from datetime import timedelta
from django.db import migrations
def set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == None:
session.duration_calculated = timedelta(0)
session.save()
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == timedelta(0):
session.duration_calculated = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0004_alter_session_duration_manual"),
]
operations = [
migrations.RunPython(
set_duration_calculated_none_to_zero,
revert_set_duration_calculated_none_to_zero,
)
]
@@ -1,38 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-01 19:18
from django.db import migrations, models
def set_finished_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
class Migration(migrations.Migration):
dependencies = [
("games", "0004_purchase_num_purchases"),
]
operations = [
migrations.AddField(
model_name="game",
name="mastered",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="status",
field=models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
default="u",
max_length=1,
),
),
migrations.RunPython(set_finished_status),
]
@@ -1,59 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-01 12:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0005_game_mastered_game_status'),
]
operations = [
migrations.AlterField(
model_name='game',
name='sort_name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='game',
name='wikidata',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AlterField(
model_name='platform',
name='group',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='converted_currency',
field=models.CharField(blank=True, default='', max_length=3),
),
migrations.AlterField(
model_name='purchase',
name='games',
field=models.ManyToManyField(related_name='purchases', to='games.game'),
),
migrations.AlterField(
model_name='purchase',
name='name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='related_purchase',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
),
migrations.AlterField(
model_name='session',
name='game',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
),
migrations.AlterField(
model_name='session',
name='note',
field=models.TextField(blank=True, default=''),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 4.1.5 on 2023-01-09 18:04
from datetime import timedelta
from django.db import migrations
def set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == None:
session.duration_manual = timedelta(0)
session.save()
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == timedelta(0):
session.duration_manual = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0005_auto_20230109_1843"),
]
operations = [
migrations.RunPython(
set_duration_manual_none_to_zero,
revert_set_duration_manual_none_to_zero,
)
]
@@ -0,0 +1,35 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0006_auto_20230109_1904"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
migrations.AlterField(
model_name="session",
name="purchase",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
),
),
]
-18
View File
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-17 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
]
operations = [
migrations.AddField(
model_name='game',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]
@@ -1,190 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-19 13:11
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
from django.db.models import F, Min
def copy_year_released(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.update(original_year_released=F("year_released"))
def set_abandoned_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game = apps.get_model("games", "Game")
PlayEvent = apps.get_model("games", "PlayEvent")
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
finished = Game.objects.filter(purchases__date_finished__isnull=False)
for game in finished:
for purchase in game.purchases.all():
first_session = game.sessions.filter(
timestamp_start__gte=purchase.date_purchased
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
first_session_date = first_session.date() if first_session else None
if purchase.date_finished:
play_event = PlayEvent(
game=game,
started=first_session_date
if first_session_date
else purchase.date_purchased,
ended=purchase.date_finished,
)
play_event.save()
def create_game_status_changes(apps, schema_editor):
Game = apps.get_model("games", "Game")
GameStatusChange = apps.get_model("games", "GameStatusChange")
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
for game in Game.objects.filter(sessions__isnull=False).distinct():
if game.sessions.exists():
earliest_session = game.sessions.earliest()
GameStatusChange.objects.create(
game=game,
old_status="u",
new_status="p",
timestamp=earliest_session.timestamp_start,
)
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_dropped,
)
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_refunded,
)
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
# consider only the first playevent
for game in Game.objects.filter(playevents__isnull=False):
first_playevent = game.playevents.first()
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="f",
timestamp=first_playevent.ended,
)
class Migration(migrations.Migration):
dependencies = [
("games", "0007_game_updated_at"),
]
operations = [
migrations.AddField(
model_name="game",
name="original_year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(copy_year_released),
migrations.CreateModel(
name="GameStatusChange",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"old_status",
models.CharField(
blank=True,
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
null=True,
),
),
(
"new_status",
models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
),
),
("timestamp", models.DateTimeField(null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="status_changes",
to="games.game",
),
),
],
options={
"ordering": ["-timestamp"],
},
),
migrations.CreateModel(
name="PlayEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("started", models.DateField(blank=True, null=True)),
("ended", models.DateField(blank=True, null=True)),
(
"days_to_finish",
models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.RawSQL(
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
[],
),
output_field=models.IntegerField(),
),
),
("note", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="playevents",
to="games.game",
),
),
],
),
migrations.RunPython(set_abandoned_status),
migrations.RunPython(create_game_status_changes),
]
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]
@@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-20 11:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='date_dropped',
),
migrations.RemoveField(
model_name='purchase',
name='date_finished',
),
]
@@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='price_per_game',
),
]
@@ -1,20 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0010_remove_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]
@@ -1,32 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:30
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_purchase_price_per_game"),
]
operations = [
migrations.RemoveField(
model_name="session",
name="duration_calculated",
),
migrations.AddField(
model_name="session",
name="duration_calculated",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.functions.comparison.Coalesce(
django.db.models.expressions.CombinedExpression(
models.F("timestamp_end"), "-", models.F("timestamp_start")
),
0,
),
output_field=models.DurationField(),
),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]
-35
View File
@@ -1,35 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:33
import datetime
from django.db import migrations, models
from django.db.models import F, Sum
def calculate_game_playtime(apps, schema_editor):
Game = apps.get_model("games", "Game")
games = Game.objects.all()
for game in games:
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_total"))
)["total_playtime"]
if total_playtime:
game.playtime = total_playtime
game.save(update_fields=["playtime"])
class Migration(migrations.Migration):
dependencies = [
("games", "0012_alter_session_duration_calculated"),
]
operations = [
migrations.AddField(
model_name="game",
name="playtime",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), editable=False
),
),
migrations.RunPython(calculate_game_playtime),
]
@@ -0,0 +1,31 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:46
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0013_game_playtime'),
]
operations = [
migrations.AddField(
model_name='session',
name='duration_total',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
),
]
@@ -1,39 +0,0 @@
# Generated by Django 5.1.7 on 2026-01-15 15:37
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0014_session_duration_total'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='date_purchased',
field=models.DateField(verbose_name='Purchased'),
),
migrations.AlterField(
model_name='purchase',
name='date_refunded',
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
),
migrations.AlterField(
model_name='session',
name='duration_manual',
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
),
migrations.AlterField(
model_name='session',
name='timestamp_end',
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
),
migrations.AlterField(
model_name='session',
name='timestamp_start',
field=models.DateTimeField(verbose_name='Start'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]
@@ -0,0 +1,51 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]
@@ -0,0 +1,141 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
import django.db.models.deletion
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
replaces = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
("games", "0017_alter_device_type_alter_purchase_platform"),
("games", "0018_auto_20231106_1825"),
("games", "0019_alter_edition_unique_together"),
("games", "0020_game_year"),
("games", "0021_auto_20231106_1909"),
("games", "0022_rename_year_game_year_released"),
]
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.RunPython(
code=rename_duplicates,
),
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(
code=update_game_year,
),
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]
@@ -0,0 +1,41 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
]
operations = [
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-11-06 17:25
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
class Migration(migrations.Migration):
dependencies = [
("games", "0017_alter_device_type_alter_purchase_platform"),
]
operations = [
migrations.RunPython(rename_duplicates),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.1.5 on 2023-11-06 17:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0018_auto_20231106_1825"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
]
@@ -0,0 +1,24 @@
from django.db import migrations
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0020_game_year"),
]
operations = [
migrations.RunPython(update_game_year),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-06 18:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0021_auto_20231106_1909"),
]
operations = [
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]
@@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-11-06 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"games",
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_finished",
field=models.DateField(blank=True, null=True),
),
]
@@ -0,0 +1,39 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Edition = apps.get_model(
"games", "Edition"
) # Replace 'your_app_name' with the actual name of your app
for edition in Edition.objects.all():
name = edition.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
edition.sort_name = sort_name
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0023_purchase_date_finished"),
]
operations = [
migrations.AddField(
model_name="edition",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]
+39
View File
@@ -0,0 +1,39 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Game = apps.get_model(
"games", "Game"
) # Replace 'your_app_name' with the actual name of your app
for game in Game.objects.all():
name = game.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
game.sort_name = sort_name
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0024_edition_sort_name"),
]
operations = [
migrations.AddField(
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]
+27
View File
@@ -0,0 +1,27 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]
+26
View File
@@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]
@@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]
@@ -0,0 +1,44 @@
# Generated by Django 4.1.5 on 2023-11-15 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0030_alter_purchase_name"),
]
operations = [
migrations.AddField(
model_name="device",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="edition",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="game",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="platform",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="session",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-11-15 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="session",
options={"get_latest_by": "timestamp_start"},
),
migrations.AddField(
model_name="session",
name="modified_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="device",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="edition",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="platform",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="session",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2023-11-28 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0032_alter_session_options_session_modified_at_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform", "year_released")},
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2024-01-03 21:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0033_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_dropped",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="purchase",
name="infinite",
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1 on 2024-08-11 15:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0034_purchase_date_dropped_purchase_infinite"),
]
operations = [
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.device",
),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1 on 2024-08-11 16:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0035_alter_session_device'),
]
operations = [
migrations.AlterField(
model_name='edition',
name='platform',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform'),
),
]
+26
View File
@@ -0,0 +1,26 @@
# Generated by Django 5.1.1 on 2024-09-14 07:05
from django.db import migrations, models
from django.utils.text import slugify
def update_empty_icons(apps, schema_editor):
Platform = apps.get_model("games", "Platform")
for platform in Platform.objects.filter(icon=""):
platform.icon = slugify(platform.name)
platform.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0036_alter_edition_platform"),
]
operations = [
migrations.AddField(
model_name="platform",
name="icon",
field=models.SlugField(blank=True),
),
migrations.RunPython(update_empty_icons),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-10-04 09:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0037_platform_icon'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='price',
field=models.FloatField(default=0),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-11-09 22:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0038_alter_purchase_price'),
]
operations = [
migrations.AlterField(
model_name='device',
name='type',
field=models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.2 on 2024-11-09 22:39
from django.db import migrations
def update_device_types(apps, schema_editor):
Device = apps.get_model("games", "Device")
# Mapping of short names to long names
type_map = {
"pc": "PC",
"co": "Console",
"ha": "Handheld",
"mo": "Mobile",
"sbc": "Single-board computer",
"un": "Unknown",
}
# Loop through all devices and update the type field
for device in Device.objects.all():
if device.type in type_map:
device.type = type_map[device.type]
device.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0039_alter_device_type"),
]
operations = [
migrations.RunPython(update_device_types),
]
@@ -0,0 +1,36 @@
# Generated by Django 5.1.3 on 2024-11-10 15:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0040_migrate_device_types'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='converted_currency',
field=models.CharField(max_length=3, null=True),
),
migrations.AddField(
model_name='purchase',
name='converted_price',
field=models.FloatField(null=True),
),
migrations.CreateModel(
name='ExchangeRate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
},
),
]
+90 -267
View File
@@ -1,63 +1,20 @@
import logging
from datetime import timedelta
import requests
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Sum
from django.db.models.expressions import RawSQL
from django.db.models.fields.generated import GeneratedField
from django.db.models.functions import Coalesce
from django.template.defaultfilters import floatformat, pluralize, slugify
from django.template.defaultfilters import slugify
from django.utils import timezone
from common.time import format_duration
logger = logging.getLogger("games")
class Game(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, blank=True, default="")
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
original_year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, blank=True, default="")
platform = models.ForeignKey(
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Status(models.TextChoices):
UNPLAYED = (
"u",
"Unplayed",
)
PLAYED = (
"p",
"Played",
)
FINISHED = (
"f",
"Finished",
)
RETIRED = (
"r",
"Retired",
)
ABANDONED = (
"a",
"Abandoned",
)
status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED)
mastered = models.BooleanField(default=False)
session_average: float | int | timedelta | None
session_count: int | None
@@ -65,39 +22,10 @@ class Game(models.Model):
def __str__(self):
return self.name
def finished(self):
return self.status == self.Status.FINISHED
def abandoned(self):
return self.status == self.Status.ABANDONED
def retired(self):
return self.status == self.Status.RETIRED
def played(self):
return self.status == self.Status.PLAYED
def unplayed(self):
return self.status == self.Status.UNPLAYED
def playtime_formatted(self):
return format_duration(self.playtime, "%2.1H")
def save(self, *args, **kwargs):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, blank=True, default="")
group = models.CharField(max_length=255, null=True, blank=True, default=None)
icon = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -110,6 +38,35 @@ class Platform(models.Model):
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
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.SET_DEFAULT, 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):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
@@ -117,6 +74,9 @@ class PurchaseQueryset(models.QuerySet):
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)
@@ -153,87 +113,54 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager()
games = models.ManyToManyField(Game, related_name="purchases")
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(verbose_name="Purchased")
date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False)
price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, blank=True, default="")
price_per_game = GeneratedField(
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
output_field=models.FloatField(),
db_persist=True,
editable=False,
)
num_purchases = models.IntegerField(default=0)
converted_currency = models.CharField(max_length=3, null=True)
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, blank=True, default="")
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def standardized_price(self):
return (
f"{floatformat(self.converted_price, 0)} {self.converted_currency}"
if self.converted_price
else None
)
@property
def has_one_item(self):
return self.games.count() == 1
@property
def standardized_name(self):
return self.name or self.first_game.name
@property
def first_game(self):
return self.games.first()
def __str__(self):
return self.standardized_name
@property
def full_name(self):
additional_info = [
str(item)
for item in [
f"{self.num_purchases} game{pluralize(self.num_purchases)}",
self.date_purchased,
self.standardized_price,
]
if item
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.standardized_name} ({', '.join(additional_info)})"
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME
def price_or_currency_differ_from(self, purchase_to_compare):
return (
self.price != purchase_to_compare.price
or self.price_currency != purchase_to_compare.price_currency
)
def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase:
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."
)
@@ -241,15 +168,12 @@ class Purchase(models.Model):
# Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields
if existing_purchase.price_or_currency_differ_from(self):
from games.tasks import currency_to
exchange_rate = get_or_create_rate(
self.price_currency, currency_to, self.date_purchased.year
)
if exchange_rate:
self.converted_price = floatformat(self.price * exchange_rate, 0)
self.converted_currency = currency_to
if (
existing_purchase.price != self.price
or existing_purchase.price_currency != self.price_currency
):
self.converted_price = None
self.converted_currency = None
super().save(*args, **kwargs)
@@ -281,30 +205,11 @@ class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
null=True,
default=None,
related_name="sessions",
)
timestamp_start = models.DateTimeField(verbose_name="Start")
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
duration_manual = models.DurationField(
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
)
duration_calculated = GeneratedField(
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
duration_total = GeneratedField(
expression=F("duration_calculated") + F("duration_manual"),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
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.SET_DEFAULT,
@@ -312,17 +217,15 @@ class Session(models.Model):
blank=True,
default=None,
)
note = models.TextField(blank=True, default="")
emulated = models.BooleanField(default=False)
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 = "*" if self.is_manual() else ""
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
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()
@@ -330,18 +233,32 @@ class Session(models.Model):
def start_now():
self.timestamp_start = timezone.now()
def duration_formatted(self) -> str:
result = format_duration(self.duration_total, "%02.1H")
return result
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if self.is_manual() and isinstance(self.duration_manual, timedelta):
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_with_mark(self) -> str:
mark = "*" if self.is_manual() else ""
return f"{self.duration_formatted()}{mark}"
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) -> None:
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 isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0)
@@ -387,97 +304,3 @@ class ExchangeRate(models.Model):
def __str__(self):
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
exchange_rate = None
result = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
)
if result:
exchange_rate = result[0].rate
else:
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
exchange_rate = exchange_rate.rate
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
return exchange_rate
class PlayEvent(models.Model):
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
started = models.DateField(null=True, blank=True)
ended = models.DateField(null=True, blank=True)
days_to_finish = GeneratedField(
# special cases:
# missing ended, started, or both = 0
# same day = 1 day to finish
expression=RawSQL(
"""
COALESCE(
CASE
WHEN date(ended) = date(started) THEN 1
ELSE julianday(ended) - julianday(started)
END, 0
)
""",
[],
),
output_field=models.IntegerField(),
db_persist=True,
editable=False,
blank=True,
)
note = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# class PlayMarker(models.Model):
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
# played_since = models.DurationField()
# played_total = models.DurationField()
# note = models.CharField(max_length=255)
class GameStatusChange(models.Model):
"""
Tracks changes to the status of a Game.
"""
game = models.ForeignKey(
Game, on_delete=models.CASCADE, related_name="status_changes"
)
old_status = models.CharField(
max_length=1, choices=Game.Status.choices, blank=True, null=True
)
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
timestamp = models.DateTimeField(null=True)
def __str__(self):
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
class Meta:
ordering = ["-timestamp"]
-89
View File
@@ -1,89 +0,0 @@
import logging
from datetime import timedelta
from django.db.models import F, Sum
from django.db.models.signals import (
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save,
)
from django.dispatch import receiver
from django.utils.timezone import now
from games.models import Game, GameStatusChange, Purchase, Session
logger = logging.getLogger("games")
@receiver(m2m_changed, sender=Purchase.games.through)
def update_num_purchases(sender, instance, action, reverse, **kwargs):
if not reverse and action.startswith("post_"):
instance.num_purchases = instance.games.count()
instance.updated_at = now()
instance.save(update_fields=["num_purchases", "updated_at"])
@receiver(pre_delete, sender=Game)
def update_purchase_counts_on_game_delete(sender, instance, **kwargs):
"""
Update num_purchases on related Purchase objects when a Game is deleted.
m2m_changed is not fired when a related object is deleted.
"""
for purchase in instance.purchases.all():
if purchase.num_purchases > 0:
purchase.num_purchases -= 1
if purchase.num_purchases == 0:
purchase.delete()
else:
purchase.updated_at = now()
purchase.save(update_fields=["num_purchases", "updated_at"])
@receiver([post_save, post_delete], sender=Session)
def update_game_playtime(sender, instance, **kwargs):
# During cascade deletes the related Game may already have been removed.
# Use the FK id to look up the Game safely and bail out if it no longer exists.
game_pk = getattr(instance, "game_id", None)
if not game_pk:
return
game = Game.objects.filter(pk=game_pk).first()
if not game:
return
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
)["total_playtime"]
game.playtime = total_playtime if total_playtime else timedelta(0)
game.save(update_fields=["playtime"])
@receiver(pre_save, sender=Game)
def game_status_changed(sender, instance, **kwargs):
"""
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
"""
try:
old_instance = sender.objects.get(pk=instance.pk)
old_status = old_instance.status
logger.info("[game_status_changed]: Previous status exists.")
except sender.DoesNotExist:
# Handle the case where the instance was deleted before the signal was sent
logger.info("[game_status_changed]: Previous status does not exist.")
return
if old_status != instance.status:
logger.info(
"[game_status_changed]: Status changed from {} to {}".format(
old_status, instance.status
)
)
GameStatusChange.objects.create(
game=instance,
old_status=old_status,
new_status=instance.status,
timestamp=now(),
)
else:
logger.info("[game_status_changed]: Status has not changed")
+3178 -3146
View File
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -7,7 +7,7 @@ import {
let syncData = [
{
source: "#id_games",
source: "#id_edition",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
@@ -21,11 +21,27 @@ function setupElementHandlers() {
"#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").addEventListener("change", () => {
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
}
}
});
-5
View File
@@ -43,7 +43,6 @@ function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
targetElement[syncItem.target_value] = valueToSync;
}
}
@@ -185,17 +184,13 @@ function disableElementsWhenValueNotEqual(
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
el.disabled = "disabled";
},
(el) => {
console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
el.disabled = "";
},
]);
+1 -1
View File
@@ -36,7 +36,7 @@ function addToggleButton(targetNode) {
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_games", "#id_platform"];
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));
+10 -43
View File
@@ -1,9 +1,4 @@
import logging
import requests
from django.template.defaultfilters import floatformat
logger = logging.getLogger("games")
from games.models import ExchangeRate, Purchase
@@ -13,8 +8,8 @@ currency_to = currency_to.upper()
def save_converted_info(purchase, converted_price, converted_currency):
logger.info(
f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})"
print(
f"Changing converted price of {purchase} to {converted_price} {converted_currency} "
)
purchase.converted_price = converted_price
purchase.converted_currency = converted_currency
@@ -23,10 +18,8 @@ def save_converted_info(purchase, converted_price, converted_currency):
def convert_prices():
purchases = Purchase.objects.filter(
converted_price__isnull=True, converted_currency=""
converted_price__isnull=True, converted_currency__isnull=True
)
if purchases.count() == 0:
logger.info("[convert_prices]: No prices to convert.")
for purchase in purchases:
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
@@ -34,57 +27,31 @@ def convert_prices():
continue
year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper()
exchange_rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
).first()
logger.info(
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
)
if not exchange_rate:
logger.info(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
)
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
rate = data[currency_from].get(currency_to)
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
rate=rate,
)
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
print(
f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
if exchange_rate:
save_converted_info(
purchase,
floatformat(purchase.price * exchange_rate.rate, 0),
currency_to,
purchase, purchase.price * exchange_rate.rate, currency_to
)
def calculate_price_per_game():
"""
This task is deprecated because price_per_game is now a GeneratedField.
It is kept here to prevent errors from lingering scheduled tasks.
"""
try:
from django_q.models import Schedule
Schedule.objects.filter(func="games.tasks.calculate_price_per_game").delete()
except Exception:
pass
+12
View File
@@ -0,0 +1,12 @@
<c-layouts.add>
<c-slot name="additional_row">
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</c-slot>
</c-layouts.add>
+8 -3
View File
@@ -1,7 +1,12 @@
<c-layouts.add>
<c-slot name="additional_row">
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
</td>
</tr>
</c-slot>
</c-layouts.add>
+3 -3
View File
@@ -1,6 +1,6 @@
<c-vars color="blue" size="base" type="button" />
<button type="{{ type }}"
<c-vars color="blue" size="base" />
<button type="button"
title="{{ title }}"
class="{{ class }} {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
class=" {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
{{ slot }}
</button>
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="inline-flex rounded-md shadow-xs" role="group">
<div class="inline-flex rounded-md shadow-sm" role="group">
{% if slot %}{{ slot }}{% endif %}
{% for button in buttons %}
{% if button.slot %}
@@ -4,19 +4,19 @@
{% if color == "gray" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ slot }}
</button>
{% elif color == "red" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ slot }}
</button>
{% elif color == "green" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white">
{{ slot }}
</button>
{% endif %}
+1 -1
View File
@@ -4,7 +4,7 @@ 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-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
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>
+1 -1
View File
@@ -5,7 +5,7 @@ text
<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-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
-16
View File
@@ -1,16 +0,0 @@
<span class="{% if display == 'flex' %}flex{% else %}inline-flex{% endif %} gap-2 items-center align-middle {{class}}">
<span class="rounded-xl w-3 h-3
{% if status == "u" %}
bg-gray-500
{% elif status == "p" %}
bg-orange-400
{% elif status == "f" %}
bg-green-500
{% elif status == "a" %}
bg-red-500
{% elif status == "r" %}
bg-purple-500
{% endif %}
">&nbsp;</span>
{{ slot }}
</span>
+1 -1
View File
@@ -1,7 +1,7 @@
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
{{ slot }}
{% if badge %}
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2">
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2">
{{ badge }}
</span>
{% endif %}
@@ -1,3 +0,0 @@
<svg class="dark:text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 284 B

@@ -1,6 +0,0 @@
<c-vars title="Emulated" />
<c-svg :title=title viewbox="0 0 48 48">
<c-slot name="path">
M 8.5 5 C 6.0324991 5 4 7.0324991 4 9.5 L 4 30.5 C 4 32.967501 6.0324991 35 8.5 35 L 17 35 L 17 40 L 13.5 40 A 1.50015 1.50015 0 1 0 13.5 43 L 18.253906 43 A 1.50015 1.50015 0 0 0 18.740234 43 L 29.253906 43 A 1.50015 1.50015 0 0 0 29.740234 43 L 34.5 43 A 1.50015 1.50015 0 1 0 34.5 40 L 31 40 L 31 35 L 39.5 35 C 41.967501 35 44 32.967501 44 30.5 L 44 9.5 C 44 7.0324991 41.967501 5 39.5 5 L 8.5 5 z M 8.5 8 L 39.5 8 C 40.346499 8 41 8.6535009 41 9.5 L 41 30.5 C 41 31.346499 40.346499 32 39.5 32 L 29.746094 32 A 1.50015 1.50015 0 0 0 29.259766 32 L 18.746094 32 A 1.50015 1.50015 0 0 0 18.259766 32 L 8.5 32 C 7.6535009 32 7 31.346499 7 30.5 L 7 9.5 C 7 8.6535009 7.6535009 8 8.5 8 z M 17.5 12 C 16.136406 12 15 13.136406 15 14.5 L 15 25.5 C 15 26.863594 16.136406 28 17.5 28 L 30.5 28 C 31.863594 28 33 26.863594 33 25.5 L 33 14.5 C 33 13.136406 31.863594 12 30.5 12 L 17.5 12 z M 18 18 L 30 18 L 30 25 L 18 25 L 18 18 z M 20 35 L 28 35 L 28 40 L 20 40 L 20 35 z
</c-slot>
</c-svg>
+1 -1
View File
@@ -2,7 +2,7 @@
x="0px"
y="0px"
viewBox="0 0 48 48"
class="w-4 h-4">
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z">
</path>
</svg>

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 861 B

+12 -11
View File
@@ -3,18 +3,19 @@
{% if form_content %}
{{ form_content }}
{% else %}
<div class="max-width-container">
<div class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data">
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_div }}
<div><input type="submit" value="Submit" /></div>
<div class="submit-button-container">
{{ additional_row }}
</div>
</form>
</div>
</div>
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
{{ additional_row }}
</table>
</form>
{% endif %}
<c-slot name="scripts">
{% if script_name %}
+34 -51
View File
@@ -12,10 +12,6 @@
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -39,59 +35,46 @@
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div>
{{ scripts }}
<script type="module">
document.addEventListener('DOMContentLoaded', () => {
if (window.mountCrownIcon) {
window.mountCrownIcon('#crown-icon-mount-point', {
mastered: {{ game.mastered|yesno:"true,false" }}
});
}
<script>
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
// Theme toggle logic
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
// Change the icons inside the button based on previous settings
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
themeToggleLightIcon.classList.remove('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
}
// Ensure all elements are found before proceeding
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
// Initial state of icons based on current theme
// The FOUC script in <head> already set document.documentElement.classList.add/remove('dark')
// So we just need to set the icon visibility based on that.
if (document.documentElement.classList.contains('dark')) {
themeToggleLightIcon.classList.remove('hidden');
themeToggleDarkIcon.classList.add('hidden');
var themeToggleBtn = document.getElementById('theme-toggle');
themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
themeToggleDarkIcon.classList.remove('hidden');
themeToggleLightIcon.classList.add('hidden');
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else { // current theme is dark, switch to light
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else { // no theme in local storage, use system preference
if (document.documentElement.classList.contains('dark')) { // currently dark, switch to light
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else { // currently light, switch to dark
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
// if NOT set via local storage previously
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
</script>
</body>
+2 -4
View File
@@ -1,10 +1,8 @@
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span>
<span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
<div data-popover
id="{{ id }}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
<div class="px-3 py-2">{{ popover_content }}</div>
<div data-popper-arrow></div>
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
<span class="hidden decoration-dotted"></span>
</div>
@@ -1 +0,0 @@
<span title="Price is a result of conversion and rounding." class="decoration-dotted underline">{{ slot }}</span>

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