28 Commits

Author SHA1 Message Date
64cce8a048 Fix currency API endpoint accepting only lowercase currency strings
All checks were successful
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 2m6s
Signed-off-by: Lukáš Kucharczyk <lukas@kucharczyk.xyz>
2025-01-30 09:44:46 +00:00
2116cfc219 Remove migrations
All checks were successful
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m12s
2025-01-29 22:28:00 +01:00
6bd8271291 Remove Edition
Some checks failed
Django CI/CD / test (push) Failing after 54s
Django CI/CD / build-and-push (push) Has been skipped
2025-01-29 22:05:06 +01:00
e571feadef Add platform to Game 2025-01-29 18:42:13 +01:00
23c1ce1f96 Set Edition related_name to editions 2025-01-29 18:02:17 +01:00
33103daebc Update Django to 5.1.5 and virtualenv to 20.29.1
All checks were successful
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-01-29 13:45:58 +01:00
ba6028e43d Add emulated property to sessions
Some checks failed
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Has been cancelled
2025-01-29 13:43:35 +01:00
c2853a3ecc purchases can now refer to multiple editions
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m36s
allows purchases to be for more than one game
2025-01-08 21:00:19 +01:00
cd90d60475 fix monthly playtime not displaying
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m12s
this bug was introduced in d622ddfbf3
2024-12-01 11:00:15 +01:00
11cea2142a dont display year if none
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m15s
2024-11-27 18:47:25 +01:00
24578b64fe disable djlint precommit hook 2024-11-27 18:47:16 +01:00
13e607f9a7 Add error handling if no Sessions exist
All checks were successful
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Successful in 2m30s
2024-11-27 18:35:44 +01:00
fc0d8db8e8 consistently format prices everywhere
All checks were successful
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-11-15 22:53:07 +01:00
8acc4f9c5b make table work better on small screens
All checks were successful
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m12s
2024-11-13 21:28:44 +01:00
6b7a96dc06 make PopoverTruncated customizable 2024-11-13 21:28:17 +01:00
5c5fd5f26a truncate: strip trailing whitespace 2024-11-13 21:07:26 +01:00
7181b6472c fix mistakenly hardcoded value in truncate() 2024-11-13 21:06:52 +01:00
af06d07ee3 pre-commit hook: disable isort 2024-11-13 21:06:38 +01:00
315e22a8ac Add yaml to dependencies
All checks were successful
Django CI/CD / test (push) Successful in 1m28s
Django CI/CD / build-and-push (push) Successful in 2m33s
2024-11-11 18:14:48 +01:00
19676f8441 Implement converting prices (#79)
All checks were successful
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m10s
Reviewed-on: #79
2024-11-11 16:36:57 +00:00
f61cde180f Pass search_string to search_field.html
All checks were successful
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-11-10 00:05:33 +01:00
a53818257c Fix being unable to override c-vars from render_from_string 2024-11-10 00:05:11 +01:00
2d3ea714c4 Extend session search 2024-11-09 23:52:09 +01:00
832bb48983 Device: safe long type names directly in database 2024-11-09 23:51:28 +01:00
c6b1badf39 add session search
All checks were successful
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m16s
2024-11-09 21:34:01 +00:00
a3ed93c154 handle non-existent icons
Some checks failed
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been cancelled
2024-11-09 21:30:13 +00:00
cf503a7b7d improve devcontainer
All checks were successful
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:56:20 +01:00
d81df6452a add dev container
All checks were successful
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:22:21 +01:00
81 changed files with 1611 additions and 2182 deletions

View File

@ -0,0 +1,25 @@
{
"name": "Django Time Tracker",
"dockerFile": "../devcontainer.Dockerfile",
"customizations": {
"vscode": {
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.defaultInterpreterPath": "/usr/local/bin/python",
"terminal.integrated.defaultProfile.linux": "bash"
},
"extensions": [
"ms-python.python",
"ms-python.debugpy",
"ms-python.vscode-pylance",
"ms-azuretools.vscode-docker",
"batisteo.vscode-django",
"charliermarsh.ruff",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}
},
"forwardPorts": [8000],
"postCreateCommand": "poetry install && poetry run python manage.py migrate && npm install && make dev",
}

View File

@ -1,20 +0,0 @@
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"]

View File

@ -7,6 +7,8 @@
* Allow deleting purchases
* Add all-time stats
* Manage purchases
* Automatically convert purchase prices
* Add emulated property to sessions
## Improved
* mark refunded purchases red on game overview

View File

@ -2,11 +2,14 @@ from random import choices as random_choices
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
@ -29,7 +32,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}>"
@ -49,6 +52,7 @@ 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:
@ -61,17 +65,43 @@ 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) -> str:
if (truncated := truncate(input_string)) != input_string:
return Popover(wrapped_content=truncated, popover_content=input_string)
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,
)
else:
return input_string
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
def A(
@ -124,22 +154,117 @@ def Div(
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Form(
action="",
method="get",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="form",
attributes=attributes + [("action", action), ("method", method)],
children=children,
)
def Icon(
name: str,
attributes: list[HTMLAttribute] = [],
):
return Component(template=f"cotton/icon/{name}.html", attributes=attributes)
try:
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
except TemplateDoesNotExist:
result = Icon(name="unspecified", attributes=attributes)
return result
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
link = reverse("view_game", args=[int(game_id)])
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!!")
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 = 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),
],
)
@ -147,21 +272,16 @@ def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeTe
return mark_safe(
A(
url=link,
children=[a_content],
),
children=[content],
)
if create_link
else content,
)
def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
),
PopoverTruncated(name),
],
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",
)
return mark_safe(content)

View File

@ -34,14 +34,31 @@ 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)]}{ellipsis}")
if len(input_string) > 30
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
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)

24
devcontainer.Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.13-slim
# Set up environment
ENV PYTHONUNBUFFERED=1
WORKDIR /workspace
# Install Poetry
RUN apt-get update && apt-get install -y \
curl \
make \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
# Copy pyproject.toml and poetry.lock for dependency installation
COPY pyproject.toml poetry.lock* ./
RUN poetry install --no-root
# Copy the rest of the application code
COPY . .
# Set up Django development server
EXPOSE 8000

View File

@ -10,10 +10,14 @@ poetry run python manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid"
kill -SIGTERM "$django_q_pid"
}
trap _term SIGTERM
echo "Starting Django-Q cluster"
poetry run python manage.py qcluster & django_q_pid=$!
echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid"
wait "$gunicorn_pid" "$django_q_pid"

View File

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

View File

@ -1,6 +1,33 @@
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
class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "games"
def ready(self):
post_migrate.connect(schedule_tasks, sender=self)
def schedule_tasks(sender, **kwargs):
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),
)
from games.models import ExchangeRate
if not ExchangeRate.objects.exists():
print("ExchangeRate table is empty. Loading fixture...")
call_command("loaddata", "exchangerates.yaml")

View File

@ -0,0 +1,112 @@
- model: games.exchangerate
pk: 1
fields:
currency_from: USD
currency_to: CZK
year: 2024
rate: 23.4
- model: games.exchangerate
pk: 2
fields:
currency_from: CNY
currency_to: CZK
year: 2024
rate: 3.267
- model: games.exchangerate
pk: 3
fields:
currency_from: USD
currency_to: CZK
year: 2019
rate: 22.466
- model: games.exchangerate
pk: 4
fields:
currency_from: USD
currency_to: CZK
year: 2023
rate: 22.63
- model: games.exchangerate
pk: 5
fields:
currency_from: USD
currency_to: CZK
year: 2017
rate: 25.819
- model: games.exchangerate
pk: 6
fields:
currency_from: USD
currency_to: CZK
year: 2013
rate: 19.023
- model: games.exchangerate
pk: 7
fields:
currency_from: CNY
currency_to: CZK
year: 2019
rate: 3.295
- model: games.exchangerate
pk: 8
fields:
currency_from: CNY
currency_to: CZK
year: 2016
rate: 3.795
- model: games.exchangerate
pk: 9
fields:
currency_from: CNY
currency_to: CZK
year: 2015
rate: 3.707
- model: games.exchangerate
pk: 10
fields:
currency_from: CNY
currency_to: CZK
year: 2020
rate: 3.26
- model: games.exchangerate
pk: 11
fields:
currency_from: EUR
currency_to: CZK
year: 2012
rate: 25.51
- model: games.exchangerate
pk: 12
fields:
currency_from: EUR
currency_to: CZK
year: 2010
rate: 26.465
- model: games.exchangerate
pk: 13
fields:
currency_from: EUR
currency_to: CZK
year: 2014
rate: 27.52
- model: games.exchangerate
pk: 14
fields:
currency_from: EUR
currency_to: CZK
year: 2024
rate: 25.21
- model: games.exchangerate
pk: 15
fields:
currency_from: EUR
currency_to: CZK
year: 2022
rate: 24.325
- model: games.exchangerate
pk: 16
fields:
currency_from: CNY
currency_to: CZK
year: 2018
rate: 3.268

View File

@ -2,7 +2,7 @@ from django import forms
from django.urls import reverse
from common.utils import safe_getattr
from games.models import Device, Edition, Game, Platform, Purchase, Session
from games.models import Device, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
@ -12,11 +12,8 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"),
game = forms.ModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
@ -29,21 +26,22 @@ class SessionForm(forms.ModelForm):
}
model = Session
fields = [
"purchase",
"game",
"timestamp_start",
"timestamp_end",
"duration_manual",
"emulated",
"device",
"note",
]
class EditionChoiceField(forms.ModelChoiceField):
class GameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.Select):
class IncludePlatformSelect(forms.SelectMultiple):
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"):
@ -56,26 +54,24 @@ class PurchaseForm(forms.ModelForm):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
# to only include purchases of the selected game.
related_purchase_by_game_url = reverse("related_purchase_by_game")
self.fields["games"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-get": related_purchase_by_game_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
games = GameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
queryset=Purchase.objects.filter(type=Purchase.GAME),
required=False,
)
@ -88,7 +84,7 @@ class PurchaseForm(forms.ModelForm):
}
model = Purchase
fields = [
"edition",
"games",
"platform",
"date_purchased",
"date_refunded",
@ -140,24 +136,14 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "sort_name", "year_released", "wikidata"]
fields = ["name", "sort_name", "platform", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}

View File

@ -1,5 +1,4 @@
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

View File

@ -1,11 +0,0 @@
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()

View File

@ -0,0 +1,24 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from django_q.models import Schedule
from django_q.tasks import schedule
class Command(BaseCommand):
help = "Manually schedule the next update_converted_prices task"
def handle(self, *args, **kwargs):
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),
)
self.stdout.write(
self.style.SUCCESS("Scheduled the update_converted_prices task.")
)
else:
self.stdout.write(self.style.WARNING("Task is already scheduled."))

View File

@ -1,5 +1,6 @@
# Generated by Django 4.1.4 on 2023-01-02 18:27
# Generated by Django 5.1.5 on 2025-01-29 21:26
import datetime
import django.db.models.deletion
from django.db import migrations, models
@ -8,94 +9,96 @@ class Migration(migrations.Migration):
initial = True
dependencies = []
dependencies = [
]
operations = [
migrations.CreateModel(
name="Game",
name='Device',
fields=[
(
"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)),
('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)),
],
),
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(max_length=255)),
('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)),
],
),
migrations.CreateModel(
name="Purchase",
name='ExchangeRate',
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)),
(
"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",
),
),
('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')),
],
),
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()),
("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",
),
),
('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')),
],
options={
'get_latest_by': 'timestamp_start',
},
),
]

View File

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

View File

@ -1,23 +0,0 @@
# 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),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
# 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")},
),
]

View File

@ -1,23 +0,0 @@
# 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),
),
]

View File

@ -1,25 +0,0 @@
# 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",
),
),
]

View File

@ -1,19 +0,0 @@
# 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'),
),
]

View File

@ -1,26 +0,0 @@
# 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),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -10,10 +10,17 @@ from common.time import format_duration
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, 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)
platform = models.ForeignKey(
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
created_at = models.DateTimeField(auto_now_add=True)
session_average: float | int | timedelta | None
@ -22,6 +29,17 @@ class Game(models.Model):
def __str__(self):
return self.name
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)
@ -38,35 +56,6 @@ 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)
@ -113,7 +102,8 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
games = models.ManyToManyField(Game, related_name="purchases", blank=True)
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
@ -124,6 +114,8 @@ class Purchase(models.Model):
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, null=True)
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
@ -139,29 +131,47 @@ class Purchase(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
@property
def standardized_name(self):
return self.name if self.name else self.first_game.name
@property
def first_game(self):
return self.games.first()
def __str__(self):
additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "",
(
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
f"{self.first_game.platform} version on {self.platform}"
if self.platform != self.first_game.platform
else self.platform
),
self.edition.year_released,
self.first_game.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
return (
f"{self.first_game} ({', '.join(filter(None, map(str, additional_info)))})"
)
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
if self.pk is not None:
# 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 != self.price
or existing_purchase.price_currency != self.price_currency
):
self.converted_price = None
self.converted_currency = None
super().save(*args, **kwargs)
@ -193,7 +203,14 @@ class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE)
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
blank=True,
null=True,
default=None,
related_name="sessions",
)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
@ -206,6 +223,8 @@ class Session(models.Model):
default=None,
)
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)
@ -213,7 +232,7 @@ class Session(models.Model):
def __str__(self):
mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = timezone.now()
@ -259,12 +278,12 @@ class Session(models.Model):
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
PC = "PC"
CONSOLE = "Console"
HANDHELD = "Handheld"
MOBILE = "Mobile"
SBC = "Single-board computer"
UNKNOWN = "Unknown"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
@ -274,8 +293,21 @@ class Device(models.Model):
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"
return f"{self.name} ({self.type})"
class ExchangeRate(models.Model):
currency_from = models.CharField(max_length=255)
currency_to = models.CharField(max_length=255)
year = models.PositiveIntegerField()
rate = models.FloatField()
class Meta:
unique_together = ("currency_from", "currency_to", "year")
def __str__(self):
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"

View File

@ -1,5 +1,113 @@
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(63 131 248 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(63 131 248 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
/*
! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com
! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com
*/
/*
@ -442,7 +550,7 @@ video {
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
[hidden]:where(:not([hidden="until-found"])) {
display: none;
}
@ -1104,114 +1212,6 @@ input:checked + .toggle-bg {
border-color: #1C64F2;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(63 131 248 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(63 131 248 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
.container {
width: 100%;
}
@ -1258,6 +1258,10 @@ input:checked + .toggle-bg {
border-width: 0;
}
.pointer-events-none {
pointer-events: none;
}
.visible {
visibility: visible;
}
@ -1290,6 +1294,11 @@ input:checked + .toggle-bg {
inset: 0px;
}
.inset-y-0 {
top: 0px;
bottom: 0px;
}
.bottom-0 {
bottom: 0px;
}
@ -1318,6 +1327,10 @@ input:checked + .toggle-bg {
right: 0.75rem;
}
.start-0 {
inset-inline-start: 0px;
}
.top-0 {
top: 0px;
}
@ -1418,6 +1431,10 @@ input:checked + .toggle-bg {
margin-inline-start: 0.625rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
@ -1535,6 +1552,10 @@ input:checked + .toggle-bg {
width: 16rem;
}
.w-80 {
width: 20rem;
}
.w-full {
width: 100%;
}
@ -1631,6 +1652,14 @@ input:checked + .toggle-bg {
resize: both;
}
.list-inside {
list-style-position: inside;
}
.list-disc {
list-style-type: disc;
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@ -1965,6 +1994,18 @@ input:checked + .toggle-bg {
padding-bottom: 1rem;
}
.pb-4 {
padding-bottom: 1rem;
}
.ps-10 {
padding-inline-start: 2.5rem;
}
.ps-3 {
padding-inline-start: 0.75rem;
}
.pt-2 {
padding-top: 0.5rem;
}
@ -2159,6 +2200,10 @@ input:checked + .toggle-bg {
text-decoration-color: #64748b;
}
.decoration-dotted {
text-decoration-style: dotted;
}
.opacity-0 {
opacity: 0;
}
@ -2607,6 +2652,11 @@ textarea:disabled:is(.dark *) {
z-index: 10;
}
.focus\:border-blue-500:focus {
--tw-border-opacity: 1;
border-color: rgb(63 131 248 / var(--tw-border-opacity));
}
.focus\:text-blue-700:focus {
--tw-text-opacity: 1;
color: rgb(26 86 219 / var(--tw-text-opacity));
@ -2634,6 +2684,11 @@ textarea:disabled:is(.dark *) {
--tw-ring-color: rgb(164 202 254 / var(--tw-ring-opacity));
}
.focus\:ring-blue-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(63 131 248 / var(--tw-ring-opacity));
}
.focus\:ring-blue-700:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(26 86 219 / var(--tw-ring-opacity));
@ -2875,6 +2930,16 @@ textarea:disabled:is(.dark *) {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
}
.odd\:dark\:bg-gray-900:is(.dark *):nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
@ -2945,6 +3010,11 @@ textarea:disabled:is(.dark *) {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(63 131 248 / var(--tw-border-opacity));
}
.dark\:focus\:text-white:focus:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@ -3059,6 +3129,10 @@ textarea:disabled:is(.dark *) {
flex-direction: row;
}
.md\:justify-between {
justify-content: space-between;
}
.md\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(2rem * var(--tw-space-x-reverse));
@ -3202,3 +3276,13 @@ textarea:disabled:is(.dark *) {
.\[\&_td\:last-child\]\:text-right td:last-child {
text-align: right;
}
@media not all and (min-width: 640px) {
.\[\&_td\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden td:not(:first-child):not(:last-child) {
display: none;
}
.\[\&_th\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden th:not(:first-child):not(:last-child) {
display: none;
}
}

View File

@ -7,7 +7,7 @@ import {
let syncData = [
{
source: "#id_edition",
source: "#id_games",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
@ -36,8 +36,8 @@ getEl("#id_type").onchange = () => {
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;
if (event.target.id === "id_games") {
var idEditionValue = document.getElementById("id_games").value;
// Condition to check - replace this with your actual logic
if (idEditionValue != "") {

View File

@ -36,7 +36,7 @@ function addToggleButton(targetNode) {
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
const toggleableFields = ["#id_games", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));

58
games/tasks.py Normal file
View File

@ -0,0 +1,58 @@
import requests
from games.models import ExchangeRate, Purchase
# fixme: save preferred currency in user model
currency_to = "CZK"
currency_to = currency_to.upper()
def save_converted_info(purchase, converted_price, converted_currency):
print(
f"Changing converted price of {purchase} to {converted_price} {converted_currency} "
)
purchase.converted_price = converted_price
purchase.converted_currency = converted_currency
purchase.save()
def convert_prices():
purchases = Purchase.objects.filter(
converted_price__isnull=True, converted_currency__isnull=True
)
for purchase in purchases:
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
save_converted_info(purchase, purchase.price, currency_to)
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()
if not exchange_rate:
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()
rate = data[currency_from].get(currency_to)
if rate:
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=rate,
)
except requests.RequestException as e:
print(
f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
if exchange_rate:
save_converted_info(
purchase, purchase.price * exchange_rate.rate, currency_to
)

View File

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

View File

@ -5,7 +5,7 @@
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
value="Submit & Create Purchase" />
</td>
</tr>
</c-slot>

View File

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

View File

@ -1,8 +1,10 @@
<span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ 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-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>

View File

@ -0,0 +1,12 @@
<c-vars :name="id" />
<div class="pb-4 bg-white dark:bg-gray-900">
<label for="table-search" class="sr-only">Search</label>
<div class="relative mt-1">
<div class="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
</div>
<input type="text" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}">
</div>
</div>

View File

@ -7,20 +7,20 @@
{{ header_action }}
</c-table-header>
{% endif %}
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody class="dark:divide-y">
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden">
{% for row in rows %}<c-table-row :data=row />{% endfor %}
</tbody>
</table>
</div>
{% if page_obj and elided_page_range %}
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
aria-label="Table navigation">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto"><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{% if page_obj.has_previous %}

View File

@ -36,8 +36,8 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
<span class="inline-block relative">
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
href="{% url 'view_game' session.purchase.edition.game.id %}">
{{ session.purchase.edition.name }}
href="{% url 'view_game' session.game.id %}">
{{ session.game.name }}
</a>
</span>
</td>

View File

@ -57,10 +57,6 @@
<a href="{% url 'add_game' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
</li>
<li>
<a href="{% url 'add_edition' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Edition</a>
</li>
<li>
<a href="{% url 'add_platform' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
@ -102,10 +98,6 @@
<a href="{% url 'list_games' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
</li>
<li>
<a href="{% url 'list_editions' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a>
</li>
<li>
<a href="{% url 'list_platforms' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>

View File

@ -2,11 +2,11 @@
{% load static %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.edition.game.id>
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
<c-gamelink :game_id=purchase.first_game.id>
{{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
</c-gamelink>
{% else %}
<c-gamelink :game_id=purchase.edition.game.id :name=purchase.edition.name />
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
{% endif %}
{% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
@ -100,7 +100,7 @@
{% endif %}
</tbody>
</table>
{% if month_playtime %}
{% if month_playtimes %}
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table">
<tbody>
@ -142,7 +142,9 @@
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
</td>
</tr>
</tbody>
</table>
@ -253,7 +255,7 @@
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
@ -274,7 +276,7 @@
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}

View File

@ -3,7 +3,7 @@
<div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
</span>
</div>
<div class="flex gap-4 dark:text-slate-400 mb-3">
@ -67,10 +67,6 @@
</a>
</div>
</div>
<c-h1 :badge="edition_count">Editions</c-h1>
<div class="mb-6">
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
</div>
<div class="mb-6">
<c-h1 :badge="purchase_count">Purchases</c-h1>
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />

View File

@ -0,0 +1,42 @@
<c-layouts.base>
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex flex-col gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{% if purchase.name %}{{ purchase.name }}{% else %}Unnamed purchase{% endif %}</span> <span class="text-slate-500 text-2xl">({{ purchase.games.count }} games)</span>
</span>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_purchase' purchase.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg 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">
Edit
</button>
</a>
<a href="{% url 'delete_purchase' purchase.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-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-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
Delete
</button>
</a>
</div>
<div>
Price:
{% if purchase.converted_price %}
{{ purchase.converted_price | floatformat }} {{ purchase.converted_currency }}
{% else %}
None
{% endif %}
({{ purchase.price | floatformat }} {{ purchase.price_currency }})
</div>
<div>
<h2 class="text-base">Items:</h2>
<ul class="list-disc list-inside">
{% for game in purchase.games.all %}
<li><c-gamelink :game_id=game.id :name=game.name /></li>
{% endfor %}
</ul>
</div>
</div>
</div>
</c-layouts.base>

View File

@ -1,6 +1,6 @@
from django.urls import path
from games.views import device, edition, game, general, platform, purchase, session
from games.views import device, game, general, platform, purchase, session
urlpatterns = [
path("", general.index, name="index"),
@ -8,19 +8,6 @@ urlpatterns = [
path("device/delete/<int:device_id>", device.delete_device, name="delete_device"),
path("device/edit/<int:device_id>", device.edit_device, name="edit_device"),
path("device/list", device.list_devices, name="list_devices"),
path("edition/add", edition.add_edition, name="add_edition"),
path(
"edition/add/for-game/<int:game_id>",
edition.add_edition,
name="add_edition_for_game",
),
path("edition/<int:edition_id>/edit", edition.edit_edition, name="edit_edition"),
path("edition/list", edition.list_editions, name="list_editions"),
path(
"edition/<int:edition_id>/delete",
edition.delete_edition,
name="delete_edition",
),
path("game/add", game.add_game, name="add_game"),
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
path("game/<int:game_id>/view", game.view_game, name="view_game"),
@ -39,6 +26,11 @@ urlpatterns = [
),
path("platform/list", platform.list_platforms, name="list_platforms"),
path("purchase/add", purchase.add_purchase, name="add_purchase"),
path(
"purchase/add/for-game/<int:game_id>",
purchase.add_purchase,
name="add_purchase_for_game",
),
path(
"purchase/<int:purchase_id>/edit",
purchase.edit_purchase,
@ -54,6 +46,11 @@ urlpatterns = [
purchase.delete_purchase,
name="delete_purchase",
),
path(
"purchase/<int:purchase_id>/view",
purchase.view_purchase,
name="view_purchase",
),
path(
"purchase/<int:purchase_id>/finish",
purchase.finish_purchase,
@ -70,20 +67,15 @@ urlpatterns = [
name="refund_purchase",
),
path(
"purchase/related-purchase-by-edition",
purchase.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path(
"purchase/add/for-edition/<int:edition_id>",
purchase.add_purchase,
name="add_purchase_for_edition",
"purchase/related-purchase-by-game",
purchase.related_purchase_by_game,
name="related_purchase_by_game",
),
path("session/add", session.add_session, name="add_session"),
path(
"session/add/for-purchase/<int:purchase_id>",
"session/add/for-game/<int:game_id>",
session.add_session,
name="add_session_for_purchase",
name="add_session_for_game",
),
path(
"session/add/from-game/<int:session_id>",
@ -116,6 +108,7 @@ urlpatterns = [
name="list_sessions_end_session",
),
path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"),
path("stats/", general.stats_alltime, name="stats_alltime"),
path(
"stats/<int:year>",

View File

@ -1,154 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.components import (
A,
Button,
Icon,
LinkedNameWithPlatformIcon,
PopoverTruncated,
)
from common.time import dateformat, local_strftime
from games.forms import EditionForm
from games.models import Edition, Game
@login_required
def list_editions(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
editions = Edition.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(editions, limit)
page_obj = paginator.get_page(page_number)
editions = page_obj.object_list
context = {
"title": "Manage editions",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"header_action": A([], Button([], "Add edition"), url="add_edition"),
"columns": [
"Game",
"Name",
"Sort Name",
"Year",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
LinkedNameWithPlatformIcon(
name=edition.name,
game_id=edition.game.id,
platform=edition.platform,
),
PopoverTruncated(
edition.name
if edition.game.name != edition.name
else "(identical)"
),
PopoverTruncated(
edition.sort_name
if edition.sort_name is not None
and edition.game.name != edition.sort_name
else "(identical)"
),
edition.year_released,
edition.wikidata,
local_strftime(edition.created_at, dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse(
"delete_edition", args=[edition.pk]
),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for edition in editions
],
},
}
return render(request, "list_purchases.html", context)
@login_required
def edit_edition(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_editions")
context: dict[str, Any] = {"form": form, "title": "Edit edition"}
return render(request, "add.html", context)
@login_required
def delete_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
edition.delete()
return redirect("list_editions")
@login_required
def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = get_object_or_404(Game, id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context)

View File

@ -13,9 +13,11 @@ from common.components import (
Button,
Div,
Icon,
NameWithPlatformIcon,
LinkedPurchase,
NameWithIcon,
Popover,
PopoverTruncated,
PurchasePrice,
)
from common.time import (
dateformat,
@ -25,9 +27,9 @@ from common.time import (
local_strftime,
timeformat,
)
from common.utils import format_float_or_int, safe_division, truncate
from common.utils import safe_division, truncate
from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session
from games.models import Game, Purchase
from games.views.general import use_custom_redirect
@ -65,18 +67,7 @@ def list_games(request: HttpRequest) -> HttpResponse:
],
"rows": [
[
A(
[
(
"href",
reverse(
"view_game",
args=[game.pk],
),
)
],
PopoverTruncated(game.name),
),
NameWithIcon(game_id=game.pk),
PopoverTruncated(
game.sort_name
if game.sort_name is not None and game.name != game.sort_name
@ -118,7 +109,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
game = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
reverse("add_purchase_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("list_games")
@ -161,27 +152,18 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
to_attr="nongame_related_purchases",
)
game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
"purchase_set",
"purchases",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased")
purchases = game.purchases.order_by("date_purchased")
sessions = Session.objects.prefetch_related("device").filter(
purchase__edition__game=game
)
sessions = game.sessions
session_count = sessions.count()
session_count_without_manual = (
Session.objects.without_manual().filter(purchase__edition__game=game).count()
)
session_count_without_manual = game.sessions.without_manual().count()
if sessions:
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
@ -202,52 +184,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
edition_data: dict[str, Any] = {
"columns": [
"Name",
"Year Released",
"Actions",
],
"rows": [
[
NameWithPlatformIcon(
name=edition.name,
platform=edition.platform,
),
edition.year_released,
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("delete_edition", args=[edition.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for edition in editions
],
}
purchase_data: dict[str, Any] = {
"columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [
[
NameWithPlatformIcon(
name=purchase.name if purchase.name else purchase.edition.name,
platform=purchase.platform,
),
LinkedPurchase(purchase),
purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat),
f"{format_float_or_int(purchase.price)} {purchase.price_currency}",
PurchasePrice(purchase),
render_to_string(
"cotton/button_group.html",
{
@ -270,9 +214,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
],
}
sessions_all = Session.objects.filter(purchase__edition__game=game).order_by(
"-timestamp_start"
)
sessions_all = game.sessions.order_by("-timestamp_start")
last_session = None
if sessions_all.exists():
last_session = sessions_all.latest()
@ -299,7 +242,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.edition.name,
popover_content=last_session.game.name,
children=[
Button(
icon=True,
@ -307,7 +250,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.purchase.edition.name}"),
truncate(f"{last_session.game.name}"),
],
)
],
@ -317,16 +260,13 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
else "",
],
),
"columns": ["Edition", "Date", "Duration", "Actions"],
"columns": ["Game", "Date", "Duration", "Actions"],
"rows": [
[
NameWithPlatformIcon(
name=session.purchase.name
if session.purchase.name
else session.purchase.edition.name,
platform=session.purchase.platform,
NameWithIcon(
session_id=session.pk,
),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
@ -370,11 +310,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
}
context: dict[str, Any] = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"purchase_count": game.purchases.count(),
"session_average_without_manual": round(
safe_division(
total_hours_without_manual, int(session_count_without_manual)
@ -385,7 +323,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"sessions": sessions,
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"edition_data": edition_data,
"purchase_data": purchase_data,
"session_data": session_data,
"session_page_obj": session_page_obj,

View File

@ -2,7 +2,7 @@ from datetime import datetime
from typing import Any, Callable
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
from django.db.models.functions import TruncDate, TruncMonth
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
@ -11,13 +11,12 @@ from django.urls import reverse
from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division
from games.models import Edition, Game, Platform, Purchase, Session
from games.models import Game, Platform, Purchase, Session
def model_counts(request: HttpRequest) -> dict[str, bool]:
return {
"game_available": Game.objects.exists(),
"edition_available": Edition.objects.exists(),
"platform_available": Platform.objects.exists(),
"purchase_available": Purchase.objects.exists(),
"session_count": Session.objects.exists(),
@ -49,7 +48,7 @@ def use_custom_redirect(
@login_required
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().select_related("purchase__edition")
this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -57,11 +56,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("edition__purchase__session"),
session_count=Count("sessions"),
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
@ -74,13 +71,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
games__sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_with_currency = this_year_purchases.select_related("games")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
@ -124,16 +119,15 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
F("sessions__duration_calculated") + F("sessions__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -148,10 +142,8 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchase__session__duration_calculated")
)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
@ -160,9 +152,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
@ -177,10 +169,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
last_play_date = "N/A"
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -228,9 +220,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
if longest_session
else 0
),
"longest_session_game": (
longest_session.purchase.edition.game if longest_session else None
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
@ -268,7 +258,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).select_related("purchase__edition")
).prefetch_related("game")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -276,13 +266,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"edition__purchase__session",
filter=Q(edition__purchase__session__timestamp_start__year=year),
"sessions",
filter=Q(sessions__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
@ -296,16 +284,14 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
games__sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_with_currency = this_year_purchases.prefetch_related("games")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
).exclude(ownership_type=Purchase.DEMO)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
@ -339,7 +325,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by(
purchases_finished_this_year.filter(games__year_released=year).order_by(
"date_finished"
)
)
@ -348,16 +334,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
F("sessions__duration_calculated") + F("sessions__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -372,10 +357,8 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchase__session__duration_calculated")
)
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
@ -384,9 +367,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
@ -405,10 +388,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -425,7 +408,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
edition__year_released=year
games__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
@ -436,16 +419,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"all_finished_this_year": purchases_finished_this_year.select_related(
"edition"
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
"games"
).order_by("date_finished"),
"all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
"edition"
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"games"
).order_by("date_finished"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related(
"edition"
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"games"
).order_by("date_finished"),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
@ -474,9 +457,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
if longest_session
else 0
),
"longest_session_game": (
longest_session.purchase.edition.game if longest_session else None
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count

View File

@ -13,11 +13,10 @@ from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from common.components import A, Button, Icon, LinkedNameWithPlatformIcon
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
from common.time import dateformat
from common.utils import format_float_or_int
from games.forms import PurchaseForm
from games.models import Edition, Purchase
from games.models import Game, Purchase
from games.views.general import use_custom_redirect
@ -49,7 +48,6 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
"Name",
"Type",
"Price",
"Currency",
"Infinite",
"Purchased",
"Refunded",
@ -60,14 +58,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
],
"rows": [
[
LinkedNameWithPlatformIcon(
name=purchase.edition.name,
game_id=purchase.edition.game.pk,
platform=purchase.platform,
),
LinkedPurchase(purchase),
purchase.get_type_display(),
format_float_or_int(purchase.price),
purchase.price_currency,
PurchasePrice(purchase),
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
@ -145,7 +138,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
@login_required
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()}
@ -156,19 +149,20 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
"add_session_for_game",
kwargs={"game_id": purchase.first_game.id},
)
)
else:
return redirect("list_purchases")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
if game_id:
game = Game.objects.get(id=game_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
"games": [game],
"platform": game.platform,
}
)
else:
@ -176,7 +170,7 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
context["form"] = form
context["title"] = "Add New Purchase"
context["script_name"] = "add_purchase.js"
# context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@ -192,7 +186,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
context["title"] = "Edit Purchase"
context["form"] = form
context["purchase_id"] = str(purchase_id)
context["script_name"] = "add_purchase.js"
# context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@ -203,6 +197,12 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("list_purchases")
@login_required
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
return render(request, "view_purchase.html", {"purchase": purchase})
@login_required
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
@ -227,12 +227,14 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("list_purchases")
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games = request.GET.getlist("games")
if not games:
return HttpResponseBadRequest("Invalid game_id")
if isinstance(games, int) or isinstance(games, str):
games = [games]
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
games__in=games, type=Purchase.GAME
).order_by("games__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})

View File

@ -2,13 +2,22 @@ from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from common.components import A, Button, Div, Icon, LinkedNameWithPlatformIcon, Popover
from common.components import (
A,
Button,
Div,
Form,
Icon,
NameWithIcon,
Popover,
)
from common.time import (
dateformat,
durationformat,
@ -19,17 +28,29 @@ from common.time import (
)
from common.utils import truncate
from games.forms import SessionForm
from games.models import Purchase, Session
from games.models import Game, Session
from games.views.general import use_custom_redirect
@login_required
def list_sessions(request: HttpRequest) -> HttpResponse:
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start")
last_session = sessions.latest()
sessions = Session.objects.order_by("-timestamp_start", "created_at")
search_string = request.GET.get("search_string", search_string)
if search_string != "":
sessions = sessions.filter(
Q(game__name__icontains=search_string)
| Q(game__name__icontains=search_string)
| Q(game__platform__name__icontains=search_string)
| Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string)
)
try:
last_session = sessions.latest()
except Session.DoesNotExist:
last_session = None
page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
@ -49,37 +70,53 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
"data": {
"header_action": Div(
children=[
A(
url="add_session",
children=Button(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
Form(
children=[
render_to_string(
"cotton/search_field.html",
{
"id": "search_string",
"search_string": search_string,
},
)
]
),
A(
url=reverse(
"list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.edition.name,
children=[
Button(
Div(
children=[
A(
url="add_session",
children=Button(
icon=True,
color="gray",
size="xs",
children=[Icon("play"), "LOG"],
),
),
A(
url=reverse(
"list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.game.name,
children=[
Icon("play"),
truncate(
f"{last_session.purchase.edition.name}"
),
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.game.name}"),
],
)
],
)
],
),
),
)
if last_session
else "",
]
),
],
attributes=[("class", "flex justify-between")],
),
"columns": [
"Name",
@ -91,12 +128,8 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
],
"rows": [
[
LinkedNameWithPlatformIcon(
name=session.purchase.edition.name,
game_id=session.purchase.edition.game.pk,
platform=session.purchase.platform,
),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
NameWithIcon(session_id=session.pk),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
@ -151,13 +184,18 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
@login_required
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
@login_required
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last()
if last != None:
initial["purchase"] = last.purchase
initial["game"] = last.game
if request.method == "POST":
form = SessionForm(request.POST or None, initial=initial)
@ -165,12 +203,12 @@ def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
form.save()
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
if game_id:
game = Game.objects.get(id=game_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
"game": game,
}
)
else:

View File

@ -4,7 +4,7 @@
"@tailwindcss/typography": "^0.5.13",
"concurrently": "^8.2.2",
"npm-check-updates": "^16.14.20",
"tailwindcss": "^3.4.4"
"tailwindcss": "^3.4.14"
},
"dependencies": {
"flowbite": "^2.4.1"

715
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,10 @@ django-template-partials = "^24.2"
markdown = "^3.6"
django-cotton = "^1.2.1"
django-q2 = "^1.7.4"
croniter = "^5.0.1"
requests = "^2.32.3"
pyyaml = "^6.0.2"
[tool.isort]
profile = "black"

View File

@ -10,7 +10,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Platform, Purchase, Session
from games.models import Game, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
@ -21,10 +21,8 @@ class PathWorksTest(TestCase):
pl.save()
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition", platform=pl)
e.save()
p = Purchase(
edition=e,
games=[e],
platform=pl,
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
)
@ -53,11 +51,6 @@ class PathWorksTest(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_edition_returns_200(self):
url = reverse("add_edition")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_purchase_returns_200(self):
url = reverse("add_purchase")
response = self.client.get(url)

View File

@ -3,14 +3,13 @@ from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Purchase, Session
from games.models import Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
@ -22,10 +21,8 @@ class FormatDurationTest(TestCase):
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition")
e.save()
p = Purchase(
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
game=g, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save()
s = Session(

View File

@ -42,8 +42,18 @@ INSTALLED_APPS = [
"graphene_django",
"django_htmx",
"django_cotton",
"django_q",
]
Q_CLUSTER = {
"name": "DjangoQ",
"workers": 4,
"recycle": 500,
"timeout": 60,
"retry": 120,
"orm": "default",
}
GRAPHENE = {"SCHEMA": "games.schema.schema"}
if DEBUG: