2 Commits

Author SHA1 Message Date
45e3cfed00 Remove extraneous statement
All checks were successful
Django CI/CD / test (push) Successful in 54s
Django CI/CD / build-and-push (push) Successful in 1m58s
2024-09-14 11:10:28 +02:00
36dd5635b2 add icon field to platform, use everywhere
All checks were successful
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Has been skipped
2024-09-14 11:07:38 +02:00
124 changed files with 2881 additions and 4673 deletions

View File

@ -1,25 +0,0 @@
{
"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

@ -15,6 +15,3 @@ indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2
[*.html]
insert_final_newline = false

20
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,20 @@
repos:
# disable due to incomaptible formatting between
# black and ruff
# TODO: replace with ruff when it works on NixOS
# - repo: https://github.com/psf/black
# rev: 24.8.0
# hooks:
# - id: black
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
hooks:
- id: djlint-reformat-django
args: ["--ignore", "H011"]
- id: djlint-django
args: ["--ignore", "H011"]

26
.vscode/launch.json vendored
View File

@ -1,26 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
},
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"args": [
"runserver"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/manage.py"
}
]
}

View File

@ -7,9 +7,6 @@
* Allow deleting purchases
* Add all-time stats
* Manage purchases
* Automatically convert purchase prices
* Add emulated property to sessions
* Add today's and last 7 days playtime stats to navbar
## Improved
* mark refunded purchases red on game overview

View File

@ -2,14 +2,11 @@ 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
@ -32,7 +29,7 @@ def Component(
attributesList = [f'{name}="{value}"' for name, value in attributes]
# make attribute list into a string
# and insert space between tag and attribute list
attributesBlob = f" {' '.join(attributesList)}"
attributesBlob = f" {" ".join(attributesList)}"
tag: str = ""
if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
@ -52,7 +49,6 @@ def randomid(seed: str = "", length: int = 10) -> str:
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
@ -65,41 +61,15 @@ def Popover(
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
],
children=children,
template="cotton/popover.html",
)
def PopoverTruncated(
input_string: str,
popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
The truncated text ends in `ellipsis`, and optionally
an always-visible `endpart` can be specified.
`popover_content` can be specified if:
1. It needs to be always displayed regardless if text is truncated.
2. It needs to differ from `input_string`.
"""
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
return Popover(
wrapped_content=truncated,
popover_content=popover_content if popover_content else input_string,
)
else:
if popover_content and popover_if_not_truncated:
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
def PopoverTruncated(input_string: str) -> str:
if (truncated := truncate(input_string)) != input_string:
return Popover(wrapped_content=truncated, popover_content=input_string)
else:
return input_string
@ -154,117 +124,22 @@ 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] = [],
):
try:
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
except TemplateDoesNotExist:
result = Icon(name="unspecified", attributes=attributes)
return result
return Component(template=f"cotton/icon/{name}.html", attributes=attributes)
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 = name or game.name
platform = game.platform
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
link = reverse("view_game", args=[int(game_id)])
content = Div(
a_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),
],
)
@ -272,16 +147,21 @@ def NameWithIcon(
return mark_safe(
A(
url=link,
children=[content],
)
if create_link
else content,
children=[a_content],
),
)
def PurchasePrice(purchase) -> str:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
),
PopoverTruncated(name),
],
)
return mark_safe(content)

View File

@ -44,9 +44,9 @@
transition: all 0.2s ease-out;
} */
/* form label {
form label {
@apply dark:text-slate-400;
} */
}
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
@ -90,37 +90,37 @@
}
}
/* form input,
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} */
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed;
@apply dark:bg-slate-700 dark:text-slate-400;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
/* @media screen and (min-width: 768px) {
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
} */
}
/* @media screen and (max-width: 768px) {
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} */
}
#button-container button {
@apply mx-1;
@ -169,27 +169,3 @@ textarea:disabled {
}
} */
label {
@apply dark:text-slate-500;
}
[type="text"], [type="password"], [type="datetime-local"], [type="datetime"], [type="date"], [type="number"], select, textarea {
@apply dark:bg-slate-600 dark:text-slate-300;
}
[type="submit"] {
@apply dark:text-white font-bold dark:bg-blue-600 px-4 py-2;
}
form div label {
@apply dark:text-white;
}
form div {
@apply flex flex-col;
}
div [type="submit"] {
@apply mt-3;
}

View File

@ -13,7 +13,7 @@ durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None):
if duration is None:
if duration == None:
return timedelta(0)
elif isinstance(duration, int):
return timedelta(seconds=duration)
@ -163,7 +163,3 @@ def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]
else:
increment_streak()
return {"days": highest_streak, "dates": highest_streak_daterange}
def available_stats_year_range():
return range(datetime.now().year, 1999, -1)

View File

@ -1,13 +1,5 @@
import operator
from dataclasses import dataclass
from datetime import date
from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
from typing import Any, Generator, TypeVar
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
@ -42,31 +34,14 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
return obj
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
def truncate(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > 30
else input_string
)
def truncate(
input_string: str, length: int = 30, ellipsis: str = "", endpart: str = ""
) -> str:
max_content_length = length - len(endpart)
if max_content_length < 0:
raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length:
return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return (
f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
)
T = TypeVar("T", str, int, date)
@ -85,83 +60,3 @@ def generate_split_ranges(
except IndexError:
end = len(value_list)
yield (value_list[start], value_list[end - 1])
def format_float_or_int(number: int | float):
return int(number) if float(number).is_integer() else f"{number:03.2f}"
OperatorType = Literal["|", "&"]
@dataclass
class FilterEntry:
condition: Q
operator: OperatorType = "&"
def build_dynamic_filter(
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
):
"""
Constructs a Django Q filter from a list of filter conditions.
Args:
filters (list): A list where each item is either:
- A Q object (default AND logic applied)
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
Returns:
Q: A combined Q object that can be passed to Django's filter().
"""
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
"|": operator.or_,
"&": operator.and_,
}
# Convert all plain Q objects into (Q, "&") for default AND behavior
processed_filters = [
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
]
# Reduce with dynamic operators
return reduce(
lambda combined_filters, filter: op_map[filter.operator](
combined_filters, filter.condition
),
processed_filters,
Q(),
)
def redirect_to(default_view: str, *default_args):
"""
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
:param default_view: The name of the default view to redirect to if 'next' is missing.
:param default_args: Any arguments required for the default view.
"""
def decorator(view_func):
@wraps(view_func)
def wrapped_view(request: HttpRequest, *args, **kwargs):
next_url = request.GET.get("next")
if not next_url:
from django.urls import (
reverse, # Import inside function to avoid circular imports
)
next_url = reverse(default_view, args=default_args)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
return redirect(next_url)
return wrapped_view
return decorator
def add_next_param_to_url(url: str, nexturl: str) -> str:
return f"{url}?{urlencode({'next': nexturl})}"

View File

@ -1,33 +0,0 @@
from datetime import datetime
import requests
url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json"
date_format = "%Y%m%d"
years = range(2000, datetime.now().year + 1)
dates = [
datetime.strftime(datetime(day=1, month=1, year=year), format=date_format)
for year in years
]
for date in dates:
final_url = url.format(date)
year = date[:4]
response = requests.get(final_url)
response.raise_for_status()
data = response.json()
if kurzy := data.get("kurzy"):
with open("output.yaml", mode="a") as o:
rates = [
f"""
- model: games.exchangerate
fields:
currency_from: {currency_name}
currency_to: CZK
year: {year}
rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)}
"""
for currency_name in ["EUR", "USD", "CNY"]
if kurzy.get(currency_name)
]
o.writelines(rates)
# time.sleep(0.5)

View File

@ -1,65 +0,0 @@
import sys
import yaml
def load_yaml(filename):
with open(filename, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or []
def save_yaml(filename, data):
with open(filename, "w", encoding="utf-8") as file:
yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False)
def extract_existing_combinations(data):
return {
(
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
for entry in data
if entry["model"] == "games.exchangerate"
}
def filter_new_entries(existing_combinations, additional_files):
new_entries = []
for filename in additional_files:
data = load_yaml(filename)
for entry in data:
if entry["model"] == "games.exchangerate":
key = (
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
if key not in existing_combinations:
new_entries.append(entry)
return new_entries
def main():
if len(sys.argv) < 3:
print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]")
sys.exit(1)
example_file = sys.argv[1]
additional_files = sys.argv[2:]
output_file = "filtered_output.yaml"
existing_data = load_yaml(example_file)
existing_combinations = extract_existing_combinations(existing_data)
new_entries = filter_new_entries(existing_combinations, additional_files)
save_yaml(output_file, new_entries)
print(f"Filtered data saved to {output_file}")
if __name__ == "__main__":
main()

View File

@ -1,24 +0,0 @@
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,14 +10,10 @@ 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" "$django_q_pid"
wait "$gunicorn_pid"

View File

@ -1,18 +1,11 @@
from django.contrib import admin
from games.models import (
Device,
ExchangeRate,
Game,
Platform,
Purchase,
Session,
)
from games.models import Device, Edition, 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,80 +0,0 @@
from datetime import date, datetime
from typing import List
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
from games.models import PlayEvent
api = NinjaAPI()
playevent_router = Router()
NOW_FACTORY = django_timezone_now
class PlayEventIn(Schema):
game_id: int
started: date | None = None
ended: date | None = None
note: str = ""
days_to_finish: int | None = None
class AutoPlayEventIn(ModelSchema):
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note"]
class UpdatePlayEventIn(Schema):
started: date | None = None
ended: date | None = None
note: str = ""
class PlayEventOut(Schema):
id: int
game: str = Field(..., alias="game.name")
started: date | None = None
ended: date | None = None
days_to_finish: int | None = None
note: str = ""
updated_at: datetime
created_at: datetime
@playevent_router.get("/", response=List[PlayEventOut])
def list_playevents(request):
return PlayEvent.objects.all()
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
return playevent
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
def get_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
return playevent
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(playevent, attr, value)
playevent.save()
return playevent
@playevent_router.delete("/{playevent_id}", response={204: None})
def delete_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return 204, None
api.add_router("/playevent", playevent_router)

View File

@ -1,46 +1,6 @@
# 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):
import games.signals # noqa: F401
post_migrate.connect(schedule_tasks, sender=self)
def schedule_tasks(sender, **kwargs):
# from django_q.models import Schedule
# from django_q.tasks import schedule
# if not Schedule.objects.filter(name="Update converted prices").exists():
# schedule(
# "games.tasks.convert_prices",
# name="Update converted prices",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
# if not Schedule.objects.filter(name="Update price per game").exists():
# schedule(
# "games.tasks.calculate_price_per_game",
# name="Update price per game",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
from games.models import ExchangeRate
if not ExchangeRate.objects.exists():
print("ExchangeRate table is empty. Loading fixture...")
call_command("loaddata", "exchangerates.yaml")

View File

@ -1,504 +0,0 @@
- 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
- model: games.exchangerate
pk: 17
fields:
currency_from: CNY
currency_to: CZK
year: 2023
rate: 3.281
- model: games.exchangerate
pk: 18
fields:
currency_from: EUR
currency_to: CZK
year: 2009
rate: 26.445
- model: games.exchangerate
pk: 19
fields:
currency_from: CNY
currency_to: CZK
year: 2025
rate: 3.35
- model: games.exchangerate
pk: 20
fields:
currency_from: EUR
currency_to: CZK
year: 2016
rate: 27.033
- model: games.exchangerate
pk: 21
fields:
currency_from: EUR
currency_to: CZK
year: 2025
rate: 25.2021966
- model: games.exchangerate
pk: 22
fields:
currency_from: EUR
currency_to: CZK
year: 2017
rate: 26.33
- model: games.exchangerate
pk: 23
fields:
currency_from: EUR
currency_to: CZK
year: 2000
rate: 36.13
- model: games.exchangerate
pk: 24
fields:
currency_from: USD
currency_to: CZK
year: 2000
rate: 35.979
- model: games.exchangerate
pk: 25
fields:
currency_from: EUR
currency_to: CZK
year: 2001
rate: 35.09
- model: games.exchangerate
pk: 26
fields:
currency_from: USD
currency_to: CZK
year: 2001
rate: 37.813
- model: games.exchangerate
pk: 27
fields:
currency_from: EUR
currency_to: CZK
year: 2002
rate: 31.98
- model: games.exchangerate
pk: 28
fields:
currency_from: USD
currency_to: CZK
year: 2002
rate: 36.259
- model: games.exchangerate
pk: 29
fields:
currency_from: EUR
currency_to: CZK
year: 2003
rate: 31.6
- model: games.exchangerate
pk: 30
fields:
currency_from: USD
currency_to: CZK
year: 2003
rate: 30.141
- model: games.exchangerate
pk: 31
fields:
currency_from: EUR
currency_to: CZK
year: 2004
rate: 32.405
- model: games.exchangerate
pk: 32
fields:
currency_from: USD
currency_to: CZK
year: 2004
rate: 25.654
- model: games.exchangerate
pk: 33
fields:
currency_from: EUR
currency_to: CZK
year: 2005
rate: 30.465
- model: games.exchangerate
pk: 34
fields:
currency_from: USD
currency_to: CZK
year: 2005
rate: 22.365
- model: games.exchangerate
pk: 35
fields:
currency_from: EUR
currency_to: CZK
year: 2006
rate: 29.005
- model: games.exchangerate
pk: 36
fields:
currency_from: USD
currency_to: CZK
year: 2006
rate: 24.588
- model: games.exchangerate
pk: 37
fields:
currency_from: CNY
currency_to: CZK
year: 2006
rate: 3.047
- model: games.exchangerate
pk: 38
fields:
currency_from: EUR
currency_to: CZK
year: 2007
rate: 27.495
- model: games.exchangerate
pk: 39
fields:
currency_from: USD
currency_to: CZK
year: 2007
rate: 20.876
- model: games.exchangerate
pk: 40
fields:
currency_from: CNY
currency_to: CZK
year: 2007
rate: 2.674
- model: games.exchangerate
pk: 41
fields:
currency_from: EUR
currency_to: CZK
year: 2008
rate: 26.62
- model: games.exchangerate
pk: 42
fields:
currency_from: USD
currency_to: CZK
year: 2008
rate: 18.078
- model: games.exchangerate
pk: 43
fields:
currency_from: CNY
currency_to: CZK
year: 2008
rate: 2.475
- model: games.exchangerate
pk: 44
fields:
currency_from: USD
currency_to: CZK
year: 2009
rate: 19.346
- model: games.exchangerate
pk: 45
fields:
currency_from: CNY
currency_to: CZK
year: 2009
rate: 2.836
- model: games.exchangerate
pk: 46
fields:
currency_from: USD
currency_to: CZK
year: 2010
rate: 18.368
- model: games.exchangerate
pk: 47
fields:
currency_from: CNY
currency_to: CZK
year: 2010
rate: 2.691
- model: games.exchangerate
pk: 48
fields:
currency_from: EUR
currency_to: CZK
year: 2011
rate: 25.06
- model: games.exchangerate
pk: 49
fields:
currency_from: USD
currency_to: CZK
year: 2011
rate: 18.751
- model: games.exchangerate
pk: 50
fields:
currency_from: CNY
currency_to: CZK
year: 2011
rate: 2.845
- model: games.exchangerate
pk: 51
fields:
currency_from: USD
currency_to: CZK
year: 2012
rate: 19.94
- model: games.exchangerate
pk: 52
fields:
currency_from: CNY
currency_to: CZK
year: 2012
rate: 3.168
- model: games.exchangerate
pk: 53
fields:
currency_from: EUR
currency_to: CZK
year: 2013
rate: 25.14
- model: games.exchangerate
pk: 54
fields:
currency_from: CNY
currency_to: CZK
year: 2013
rate: 3.059
- model: games.exchangerate
pk: 55
fields:
currency_from: USD
currency_to: CZK
year: 2014
rate: 19.894
- model: games.exchangerate
pk: 56
fields:
currency_from: CNY
currency_to: CZK
year: 2014
rate: 3.286
- model: games.exchangerate
pk: 57
fields:
currency_from: EUR
currency_to: CZK
year: 2015
rate: 27.725
- model: games.exchangerate
pk: 58
fields:
currency_from: USD
currency_to: CZK
year: 2015
rate: 22.834
- model: games.exchangerate
pk: 59
fields:
currency_from: USD
currency_to: CZK
year: 2016
rate: 24.824
- model: games.exchangerate
pk: 60
fields:
currency_from: CNY
currency_to: CZK
year: 2017
rate: 3.693
- model: games.exchangerate
pk: 61
fields:
currency_from: EUR
currency_to: CZK
year: 2018
rate: 25.54
- model: games.exchangerate
pk: 62
fields:
currency_from: USD
currency_to: CZK
year: 2018
rate: 21.291
- model: games.exchangerate
pk: 63
fields:
currency_from: EUR
currency_to: CZK
year: 2019
rate: 25.725
- model: games.exchangerate
pk: 64
fields:
currency_from: EUR
currency_to: CZK
year: 2020
rate: 25.41
- model: games.exchangerate
pk: 65
fields:
currency_from: USD
currency_to: CZK
year: 2020
rate: 22.621
- model: games.exchangerate
pk: 66
fields:
currency_from: EUR
currency_to: CZK
year: 2021
rate: 26.245
- model: games.exchangerate
pk: 67
fields:
currency_from: USD
currency_to: CZK
year: 2021
rate: 21.387
- model: games.exchangerate
pk: 68
fields:
currency_from: CNY
currency_to: CZK
year: 2021
rate: 3.273
- model: games.exchangerate
pk: 69
fields:
currency_from: USD
currency_to: CZK
year: 2022
rate: 21.951
- model: games.exchangerate
pk: 70
fields:
currency_from: CNY
currency_to: CZK
year: 2022
rate: 3.458
- model: games.exchangerate
pk: 71
fields:
currency_from: EUR
currency_to: CZK
year: 2023
rate: 24.115
- model: games.exchangerate
pk: 72
fields:
currency_from: USD
currency_to: CZK
year: 2025
rate: 24.237

View File

@ -1,17 +1,8 @@
from django import forms
from django.db import transaction
from django.urls import reverse
from common.utils import safe_getattr
from games.models import (
Device,
Game,
GameStatusChange,
Platform,
PlayEvent,
Purchase,
Session,
)
from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
@ -20,37 +11,17 @@ custom_datetime_widget = forms.DateTimeInput(
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SingleGameChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
# purchase = forms.ModelChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
duration_manual = forms.DurationField(
required=False,
widget=forms.TextInput(
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
mark_as_played = forms.BooleanField(
required=False,
initial={"mark_as_played": True},
label="Set game status to Played if Unplayed",
)
class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
@ -58,30 +29,21 @@ class SessionForm(forms.ModelForm):
}
model = Session
fields = [
"game",
"purchase",
"timestamp_start",
"timestamp_end",
"duration_manual",
"emulated",
"device",
"note",
"mark_as_played",
]
def save(self, commit=True):
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_played"):
game_instance = session.game
if game_instance.status == "u":
game_instance.status = "p"
if commit:
game_instance.save()
if commit:
session.save()
return session
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.SelectMultiple):
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"):
@ -94,50 +56,44 @@ class PurchaseForm(forms.ModelForm):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected game.
related_purchase_by_game_url = reverse("related_purchase_by_game")
self.fields["games"].widget.attrs.update(
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_game_url,
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME),
required=False,
)
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
label="Currency",
required=False,
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
}
model = Purchase
fields = [
"games",
"edition",
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"date_dropped",
"infinite",
"price",
"price_currency",
@ -184,23 +140,24 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name
class GameForm(forms.ModelForm):
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = [
"name",
"sort_name",
"platform",
"original_year_released",
"year_released",
"status",
"mastered",
"wikidata",
]
fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}
@ -220,48 +177,3 @@ class DeviceForm(forms.ModelForm):
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
mark_as_finished = forms.BooleanField(
required=False,
initial={"mark_as_finished": True},
label="Set game status to Finished",
)
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note", "mark_as_finished"]
widgets = {
"started": custom_date_widget,
"ended": custom_date_widget,
}
def save(self, commit=True):
with transaction.atomic():
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_finished"):
game_instance = session.game
game_instance.status = "f"
game_instance.save()
session.save()
return session
class GameStatusChangeForm(forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
"game",
"old_status",
"new_status",
"timestamp",
]
widgets = {
"timestamp": custom_datetime_widget,
}

View File

@ -1,4 +1,5 @@
from .device import Query as DeviceQuery
from .edition import Query as EditionQuery
from .game import Query as GameQuery
from .platform import Query as PlatformQuery
from .purchase import Query as PurchaseQuery

View File

@ -0,0 +1,11 @@
import graphene
from games.graphql.types import Edition
from games.models import Game as EditionModel
class Query(graphene.ObjectType):
editions = graphene.List(Edition)
def resolve_editions(self, info, **kwargs):
return EditionModel.objects.all()

View File

@ -1,24 +0,0 @@
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,6 +1,5 @@
# Generated by Django 5.1.5 on 2025-01-29 21:26
# Generated by Django 4.1.4 on 2023-01-02 18:27
import datetime
import django.db.models.deletion
from django.db import migrations, models
@ -9,96 +8,94 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Device',
name="Game",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("wikidata", models.CharField(max_length=50)),
],
),
migrations.CreateModel(
name='Platform',
name="Platform",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
('icon', models.SlugField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("group", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='ExchangeRate',
name="Purchase",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
},
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date_purchased", models.DateField()),
("date_refunded", models.DateField(blank=True, null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
migrations.CreateModel(
name='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(blank=True, null=True)),
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
('duration_calculated', models.DurationField(blank=True, null=True)),
('note', models.TextField(blank=True, null=True)),
('emulated', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timestamp_start", models.DateTimeField()),
("timestamp_end", models.DateTimeField()),
("duration_manual", models.DurationField(blank=True, null=True)),
("duration_calculated", models.DurationField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
(
"purchase",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.purchase",
),
),
],
options={
'get_latest_by': 'timestamp_start',
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.1.4 on 2023-01-02 18:55
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.FloatField(null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.4 on 2023-01-02 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0002_alter_session_duration_manual"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(blank=True, null=True),
),
migrations.AlterField(
model_name="session",
name="timestamp_end",
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0002_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

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

View File

@ -1,28 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:57
from django.db import migrations, models
from django.db.models import Count
def initialize_num_purchases(apps, schema_editor):
Purchase = apps.get_model("games", "Purchase")
purchases = Purchase.objects.annotate(num_games=Count("games"))
for purchase in purchases:
purchase.num_purchases = purchase.num_games
purchase.save(update_fields=["num_purchases"])
class Migration(migrations.Migration):
dependencies = [
("games", "0003_purchase_updated_at"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="num_purchases",
field=models.IntegerField(default=0),
),
migrations.RunPython(initialize_num_purchases),
]

View File

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

View File

@ -1,38 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-01 19:18
from django.db import migrations, models
def set_finished_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
class Migration(migrations.Migration):
dependencies = [
("games", "0004_purchase_num_purchases"),
]
operations = [
migrations.AddField(
model_name="game",
name="mastered",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="status",
field=models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
default="u",
max_length=1,
),
),
migrations.RunPython(set_finished_status),
]

View File

@ -1,59 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-01 12:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0005_game_mastered_game_status'),
]
operations = [
migrations.AlterField(
model_name='game',
name='sort_name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='game',
name='wikidata',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AlterField(
model_name='platform',
name='group',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='converted_currency',
field=models.CharField(blank=True, default='', max_length=3),
),
migrations.AlterField(
model_name='purchase',
name='games',
field=models.ManyToManyField(related_name='purchases', to='games.game'),
),
migrations.AlterField(
model_name='purchase',
name='name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='related_purchase',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
),
migrations.AlterField(
model_name='session',
name='game',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
),
migrations.AlterField(
model_name='session',
name='note',
field=models.TextField(blank=True, default=''),
),
]

View File

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

View File

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

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-17 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
]
operations = [
migrations.AddField(
model_name='game',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

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

View File

@ -1,190 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-19 13:11
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
from django.db.models import F, Min
def copy_year_released(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.update(original_year_released=F("year_released"))
def set_abandoned_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game = apps.get_model("games", "Game")
PlayEvent = apps.get_model("games", "PlayEvent")
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
finished = Game.objects.filter(purchases__date_finished__isnull=False)
for game in finished:
for purchase in game.purchases.all():
first_session = game.sessions.filter(
timestamp_start__gte=purchase.date_purchased
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
first_session_date = first_session.date() if first_session else None
if purchase.date_finished:
play_event = PlayEvent(
game=game,
started=first_session_date
if first_session_date
else purchase.date_purchased,
ended=purchase.date_finished,
)
play_event.save()
def create_game_status_changes(apps, schema_editor):
Game = apps.get_model("games", "Game")
GameStatusChange = apps.get_model("games", "GameStatusChange")
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
for game in Game.objects.filter(sessions__isnull=False).distinct():
if game.sessions.exists():
earliest_session = game.sessions.earliest()
GameStatusChange.objects.create(
game=game,
old_status="u",
new_status="p",
timestamp=earliest_session.timestamp_start,
)
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_dropped,
)
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_refunded,
)
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
# consider only the first playevent
for game in Game.objects.filter(playevents__isnull=False):
first_playevent = game.playevents.first()
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="f",
timestamp=first_playevent.ended,
)
class Migration(migrations.Migration):
dependencies = [
("games", "0007_game_updated_at"),
]
operations = [
migrations.AddField(
model_name="game",
name="original_year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(copy_year_released),
migrations.CreateModel(
name="GameStatusChange",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"old_status",
models.CharField(
blank=True,
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
null=True,
),
),
(
"new_status",
models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
),
),
("timestamp", models.DateTimeField(null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="status_changes",
to="games.game",
),
),
],
options={
"ordering": ["-timestamp"],
},
),
migrations.CreateModel(
name="PlayEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("started", models.DateField(blank=True, null=True)),
("ended", models.DateField(blank=True, null=True)),
(
"days_to_finish",
models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.RawSQL(
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
[],
),
output_field=models.IntegerField(),
),
),
("note", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="playevents",
to="games.game",
),
),
],
),
migrations.RunPython(set_abandoned_status),
migrations.RunPython(create_game_status_changes),
]

View File

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

View File

@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-20 11:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='date_dropped',
),
migrations.RemoveField(
model_name='purchase',
name='date_finished',
),
]

View File

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

View File

@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='price_per_game',
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0010_remove_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
),
]

View File

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

View File

@ -1,32 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:30
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_purchase_price_per_game"),
]
operations = [
migrations.RemoveField(
model_name="session",
name="duration_calculated",
),
migrations.AddField(
model_name="session",
name="duration_calculated",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.functions.comparison.Coalesce(
django.db.models.expressions.CombinedExpression(
models.F("timestamp_end"), "-", models.F("timestamp_start")
),
0,
),
output_field=models.DurationField(),
),
),
]

View File

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

View File

@ -1,35 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:33
import datetime
from django.db import migrations, models
from django.db.models import F, Sum
def calculate_game_playtime(apps, schema_editor):
Game = apps.get_model("games", "Game")
games = Game.objects.all()
for game in games:
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_total"))
)["total_playtime"]
if total_playtime:
game.playtime = total_playtime
game.save(update_fields=["playtime"])
class Migration(migrations.Migration):
dependencies = [
("games", "0012_alter_session_duration_calculated"),
]
operations = [
migrations.AddField(
model_name="game",
name="playtime",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), editable=False
),
),
migrations.RunPython(calculate_game_playtime),
]

View File

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

View File

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

View File

@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:46
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0013_game_playtime'),
]
operations = [
migrations.AddField(
model_name='session',
name='duration_total',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2023-11-28 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0032_alter_session_options_session_modified_at_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform", "year_released")},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2024-01-03 21:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0033_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_dropped",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="purchase",
name="infinite",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1 on 2024-08-11 15:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0034_purchase_date_dropped_purchase_infinite"),
]
operations = [
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.device",
),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1 on 2024-08-11 16:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0035_alter_session_device'),
]
operations = [
migrations.AlterField(
model_name='edition',
name='platform',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.1.1 on 2024-09-14 07:05
from django.db import migrations, models
from django.utils.text import slugify
def update_empty_icons(apps, schema_editor):
Platform = apps.get_model("games", "Platform")
for platform in Platform.objects.filter(icon=""):
platform.icon = slugify(platform.name)
platform.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0036_alter_edition_platform"),
]
operations = [
migrations.AddField(
model_name="platform",
name="icon",
field=models.SlugField(blank=True),
),
migrations.RunPython(update_empty_icons),
]

View File

@ -1,63 +1,20 @@
import logging
from datetime import timedelta
import requests
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Sum
from django.db.models.expressions import RawSQL
from django.db.models.fields.generated import GeneratedField
from django.db.models.functions import Coalesce
from django.template.defaultfilters import floatformat, pluralize, slugify
from django.template.defaultfilters import slugify
from django.utils import timezone
from common.time import format_duration
logger = logging.getLogger("games")
class Game(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, blank=True, default="")
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
original_year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, blank=True, default="")
platform = models.ForeignKey(
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Status(models.TextChoices):
UNPLAYED = (
"u",
"Unplayed",
)
PLAYED = (
"p",
"Played",
)
FINISHED = (
"f",
"Finished",
)
RETIRED = (
"r",
"Retired",
)
ABANDONED = (
"a",
"Abandoned",
)
status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED)
mastered = models.BooleanField(default=False)
session_average: float | int | timedelta | None
session_count: int | None
@ -65,39 +22,10 @@ class Game(models.Model):
def __str__(self):
return self.name
def finished(self):
return self.status == self.Status.FINISHED
def abandoned(self):
return self.status == self.Status.ABANDONED
def retired(self):
return self.status == self.Status.RETIRED
def played(self):
return self.status == self.Status.PLAYED
def unplayed(self):
return self.status == self.Status.UNPLAYED
def playtime_formatted(self):
return format_duration(self.playtime, "%2.1H")
def save(self, *args, **kwargs):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, blank=True, default="")
group = models.CharField(max_length=255, null=True, blank=True, default=None)
icon = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@ -110,6 +38,24 @@ class Platform(models.Model):
super().save(*args, **kwargs)
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
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
@ -117,6 +63,9 @@ class PurchaseQueryset(models.QuerySet):
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self):
return self.filter(type=Purchase.GAME)
@ -153,103 +102,55 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager()
games = models.ManyToManyField(Game, related_name="purchases")
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField(verbose_name="Purchased")
date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False)
price = models.FloatField(default=0)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, blank=True, default="")
price_per_game = GeneratedField(
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
output_field=models.FloatField(),
db_persist=True,
editable=False,
)
num_purchases = models.IntegerField(default=0)
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, blank=True, default="")
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def standardized_price(self):
return (
f"{floatformat(self.converted_price, 0)} {self.converted_currency}"
if self.converted_price
else None
)
@property
def has_one_item(self):
return self.games.count() == 1
@property
def standardized_name(self):
return self.name or self.first_game.name
@property
def first_game(self):
return self.games.first()
def __str__(self):
return self.standardized_name
@property
def full_name(self):
additional_info = [
str(item)
for item in [
f"{self.num_purchases} game{pluralize(self.num_purchases)}",
self.date_purchased,
self.standardized_price,
self.get_type_display() if self.type != Purchase.GAME else "",
(
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
else self.platform
),
self.edition.year_released,
self.get_ownership_type_display(),
]
if item
]
return f"{self.standardized_name} ({', '.join(additional_info)})"
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME
def price_or_currency_differ_from(self, purchase_to_compare):
return (
self.price != purchase_to_compare.price
or self.price_currency != purchase_to_compare.price_currency
)
def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase:
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
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_or_currency_differ_from(self):
from games.tasks import currency_to
exchange_rate = get_or_create_rate(
self.price_currency, currency_to, self.date_purchased.year
)
if exchange_rate:
self.converted_price = floatformat(self.price * exchange_rate, 0)
self.converted_currency = currency_to
super().save(*args, **kwargs)
@ -281,30 +182,11 @@ class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
null=True,
default=None,
related_name="sessions",
)
timestamp_start = models.DateTimeField(verbose_name="Start")
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
duration_manual = models.DurationField(
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
)
duration_calculated = GeneratedField(
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
duration_total = GeneratedField(
expression=F("duration_calculated") + F("duration_manual"),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey(
"Device",
on_delete=models.SET_DEFAULT,
@ -312,17 +194,15 @@ class Session(models.Model):
blank=True,
default=None,
)
note = models.TextField(blank=True, default="")
emulated = models.BooleanField(default=False)
note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager()
def __str__(self):
mark = "*" if self.is_manual() else ""
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = timezone.now()
@ -330,18 +210,32 @@ class Session(models.Model):
def start_now():
self.timestamp_start = timezone.now()
def duration_formatted(self) -> str:
result = format_duration(self.duration_total, "%02.1H")
return result
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if self.is_manual() and isinstance(self.duration_manual, timedelta):
manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start
return timedelta(seconds=(manual + calculated).total_seconds())
def duration_formatted_with_mark(self) -> str:
mark = "*" if self.is_manual() else ""
return f"{self.duration_formatted()}{mark}"
def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs) -> None:
if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start
else:
self.duration_calculated = timedelta(0)
if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0)
@ -354,12 +248,12 @@ class Session(models.Model):
class Device(models.Model):
PC = "PC"
CONSOLE = "Console"
HANDHELD = "Handheld"
MOBILE = "Mobile"
SBC = "Single-board computer"
UNKNOWN = "Unknown"
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
@ -369,115 +263,8 @@ class Device(models.Model):
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.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})"
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
exchange_rate = None
result = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
)
if result:
exchange_rate = result[0].rate
else:
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
exchange_rate = exchange_rate.rate
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
return exchange_rate
class PlayEvent(models.Model):
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
started = models.DateField(null=True, blank=True)
ended = models.DateField(null=True, blank=True)
days_to_finish = GeneratedField(
# special cases:
# missing ended, started, or both = 0
# same day = 1 day to finish
expression=RawSQL(
"""
COALESCE(
CASE
WHEN date(ended) = date(started) THEN 1
ELSE julianday(ended) - julianday(started)
END, 0
)
""",
[],
),
output_field=models.IntegerField(),
db_persist=True,
editable=False,
blank=True,
)
note = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# class PlayMarker(models.Model):
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
# played_since = models.DurationField()
# played_total = models.DurationField()
# note = models.CharField(max_length=255)
class GameStatusChange(models.Model):
"""
Tracks changes to the status of a Game.
"""
game = models.ForeignKey(
Game, on_delete=models.CASCADE, related_name="status_changes"
)
old_status = models.CharField(
max_length=1, choices=Game.Status.choices, blank=True, null=True
)
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
timestamp = models.DateTimeField(null=True)
def __str__(self):
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
class Meta:
ordering = ["-timestamp"]
return f"{self.name} ({self.get_type_display()})"

View File

@ -1,58 +0,0 @@
import logging
from datetime import timedelta
from django.db.models import F, Sum
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save
from django.dispatch import receiver
from django.utils.timezone import now
from games.models import Game, GameStatusChange, Purchase, Session
logger = logging.getLogger("games")
@receiver(m2m_changed, sender=Purchase.games.through)
def update_num_purchases(sender, instance, **kwargs):
instance.num_purchases = instance.games.count()
instance.updated_at = now()
instance.save(update_fields=["num_purchases"])
@receiver([post_save, post_delete], sender=Session)
def update_game_playtime(sender, instance, **kwargs):
game = instance.game
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
)["total_playtime"]
game.playtime = total_playtime if total_playtime else timedelta(0)
game.save(update_fields=["playtime"])
@receiver(pre_save, sender=Game)
def game_status_changed(sender, instance, **kwargs):
"""
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
"""
try:
old_instance = sender.objects.get(pk=instance.pk)
old_status = old_instance.status
logger.info("[game_status_changed]: Previous status exists.")
except sender.DoesNotExist:
# Handle the case where the instance was deleted before the signal was sent
logger.info("[game_status_changed]: Previous status does not exist.")
return
if old_status != instance.status:
logger.info(
"[game_status_changed]: Status changed from {} to {}".format(
old_status, instance.status
)
)
GameStatusChange.objects.create(
game=instance,
old_status=old_status,
new_status=instance.status,
timestamp=now(),
)
else:
logger.info("[game_status_changed]: Status has not changed")

View File

@ -1,113 +1,5 @@
*, ::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.14 | MIT License | https://tailwindcss.com
! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com
*/
/*
@ -550,7 +442,7 @@ video {
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden]:where(:not([hidden="until-found"])) {
[hidden] {
display: none;
}
@ -1212,6 +1104,114 @@ 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,10 +1258,6 @@ input:checked + .toggle-bg {
border-width: 0;
}
.pointer-events-none {
pointer-events: none;
}
.visible {
visibility: visible;
}
@ -1294,19 +1290,6 @@ input:checked + .toggle-bg {
inset: 0px;
}
.inset-y-0 {
top: 0px;
bottom: 0px;
}
.-left-3 {
left: -0.75rem;
}
.-left-\[1px\] {
left: -1px;
}
.bottom-0 {
bottom: 0px;
}
@ -1335,26 +1318,14 @@ input:checked + .toggle-bg {
right: 0.75rem;
}
.start-0 {
inset-inline-start: 0px;
}
.top-0 {
top: 0px;
}
.top-2 {
top: 0.5rem;
}
.top-3 {
top: 0.75rem;
}
.top-\[100\%\] {
top: 100%;
}
.z-10 {
z-index: 10;
}
@ -1431,10 +1402,6 @@ input:checked + .toggle-bg {
margin-inline-end: 0.5rem;
}
.ml-3 {
margin-left: 0.75rem;
}
.mr-4 {
margin-right: 1rem;
}
@ -1451,10 +1418,6 @@ input:checked + .toggle-bg {
margin-inline-start: 0.625rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
@ -1508,10 +1471,6 @@ input:checked + .toggle-bg {
height: 3rem;
}
.h-2 {
height: 0.5rem;
}
.h-2\.5 {
height: 0.625rem;
}
@ -1548,10 +1507,6 @@ input:checked + .toggle-bg {
width: 2.5rem;
}
.w-2 {
width: 0.5rem;
}
.w-2\.5 {
width: 0.625rem;
}
@ -1560,10 +1515,6 @@ input:checked + .toggle-bg {
width: 6rem;
}
.w-3 {
width: 0.75rem;
}
.w-4 {
width: 1rem;
}
@ -1584,14 +1535,6 @@ input:checked + .toggle-bg {
width: 16rem;
}
.w-80 {
width: 20rem;
}
.w-auto {
width: auto;
}
.w-full {
width: 100%;
}
@ -1612,10 +1555,6 @@ input:checked + .toggle-bg {
max-width: 24rem;
}
.max-w-xl {
max-width: 36rem;
}
.max-w-xs {
max-width: 20rem;
}
@ -1692,14 +1631,6 @@ 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));
}
@ -1744,10 +1675,6 @@ input:checked + .toggle-bg {
justify-content: space-between;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
@ -1760,10 +1687,6 @@ input:checked + .toggle-bg {
gap: 1.25rem;
}
.gap-y-4 {
row-gap: 1rem;
}
.-space-x-px > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(-1px * var(--tw-space-x-reverse));
@ -1835,15 +1758,6 @@ input:checked + .toggle-bg {
border-radius: 0.125rem;
}
.rounded-xl {
border-radius: 0.75rem;
}
.rounded-b-md {
border-bottom-right-radius: 0.375rem;
border-bottom-left-radius: 0.375rem;
}
.rounded-e-lg {
border-start-end-radius: 0.5rem;
border-end-end-radius: 0.5rem;
@ -1864,14 +1778,6 @@ input:checked + .toggle-bg {
border-end-start-radius: 0.5rem;
}
.rounded-tl-none {
border-top-left-radius: 0px;
}
.rounded-tr-md {
border-top-right-radius: 0.375rem;
}
.border {
border-width: 1px;
}
@ -1880,18 +1786,6 @@ input:checked + .toggle-bg {
border-width: 0px;
}
.border-b {
border-bottom-width: 1px;
}
.border-e {
border-inline-end-width: 1px;
}
.border-t {
border-top-width: 1px;
}
.border-blue-600 {
--tw-border-opacity: 1;
border-color: rgb(28 100 242 / var(--tw-border-opacity));
@ -1957,29 +1851,15 @@ input:checked + .toggle-bg {
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.bg-gray-500 {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
.bg-gray-800 {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
.bg-gray-800\/20 {
background-color: rgb(31 41 55 / 0.2);
}
.bg-gray-900\/50 {
background-color: rgb(17 24 39 / 0.5);
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(14 159 110 / var(--tw-bg-opacity));
}
.bg-green-600 {
--tw-bg-opacity: 1;
background-color: rgb(5 122 85 / var(--tw-bg-opacity));
@ -1990,21 +1870,6 @@ input:checked + .toggle-bg {
background-color: rgb(4 108 78 / var(--tw-bg-opacity));
}
.bg-orange-400 {
--tw-bg-opacity: 1;
background-color: rgb(255 138 76 / var(--tw-bg-opacity));
}
.bg-purple-500 {
--tw-bg-opacity: 1;
background-color: rgb(144 97 249 / var(--tw-bg-opacity));
}
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(240 82 82 / var(--tw-bg-opacity));
}
.bg-red-700 {
--tw-bg-opacity: 1;
background-color: rgb(200 30 30 / var(--tw-bg-opacity));
@ -2100,18 +1965,6 @@ 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;
}
@ -2136,10 +1989,6 @@ input:checked + .toggle-bg {
vertical-align: top;
}
.align-middle {
vertical-align: middle;
}
.font-mono {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
@ -2282,16 +2131,16 @@ input:checked + .toggle-bg {
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(224 36 36 / var(--tw-text-opacity));
}
.text-slate-300 {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity));
}
.text-slate-400 {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.text-slate-500 {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
@ -2315,10 +2164,6 @@ input:checked + .toggle-bg {
text-decoration-color: #64748b;
}
.decoration-dotted {
text-decoration-style: dotted;
}
.opacity-0 {
opacity: 0;
}
@ -2364,12 +2209,6 @@ input:checked + .toggle-bg {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.backdrop-blur-lg {
--tw-backdrop-blur: blur(16px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
@ -2470,9 +2309,10 @@ input:checked + .toggle-bg {
transition: all 0.2s ease-out;
} */
/* form label {
@apply dark:text-slate-400;
} */
form label:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.responsive-table {
margin-left: auto;
@ -2511,25 +2351,25 @@ input:checked + .toggle-bg {
border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
}
/* form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} */
form input:disabled,
select:disabled,
textarea:disabled {
cursor: not-allowed;
form input:is(.dark *),
select:is(.dark *),
textarea:is(.dark *) {
border-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(15 23 42 / var(--tw-border-opacity));
--tw-bg-opacity: 1;
background-color: rgb(100 116 139 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(241 245 249 / var(--tw-text-opacity));
}
form input:disabled:is(.dark *),
select:disabled:is(.dark *),
textarea:disabled:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.errorlist {
@ -2545,21 +2385,21 @@ textarea:disabled:is(.dark *) {
color: rgb(226 232 240 / var(--tw-text-opacity));
}
/* @media screen and (min-width: 768px) {
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
} */
}
/* @media screen and (max-width: 768px) {
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} */
}
#button-container button {
margin-left: 0.25rem;
@ -2668,47 +2508,6 @@ textarea:disabled:is(.dark *) {
}
} */
label:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
[type="text"]:is(.dark *), [type="password"]:is(.dark *), [type="datetime-local"]:is(.dark *), [type="datetime"]:is(.dark *), [type="date"]:is(.dark *), [type="number"]:is(.dark *), select:is(.dark *), textarea:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(71 85 105 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity));
}
[type="submit"] {
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
font-weight: 700;
}
[type="submit"]:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(28 100 242 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
form div label:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
form div {
display: flex;
flex-direction: column;
}
div [type="submit"] {
margin-top: 0.75rem;
}
.odd\:bg-white:nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@ -2813,11 +2612,6 @@ div [type="submit"] {
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));
@ -2845,11 +2639,6 @@ div [type="submit"] {
--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));
@ -3091,16 +2880,6 @@ div [type="submit"] {
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));
@ -3171,11 +2950,6 @@ div [type="submit"] {
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));
@ -3290,10 +3064,6 @@ div [type="submit"] {
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));
@ -3418,6 +3188,10 @@ div [type="submit"] {
border-end-end-radius: 0.5rem;
}
.\[\&_\:last-child\]\:text-right :last-child {
text-align: right;
}
.\[\&_a\]\:underline a {
text-decoration-line: underline;
}
@ -3437,13 +3211,3 @@ div [type="submit"] {
.\[\&_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_games",
source: "#id_edition",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
@ -21,6 +21,11 @@ function setupElementHandlers() {
"#id_name",
"#id_related_purchase",
]);
disableElementsWhenValueNotEqual(
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
@ -31,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_games") {
var idEditionValue = document.getElementById("id_games").value;
if (event.target.id === "id_edition") {
var idEditionValue = document.getElementById("id_edition").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_games", "#id_platform"];
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));

View File

@ -1,94 +0,0 @@
import requests
from django.db.models import ExpressionWrapper, F, FloatField, Q
from django.template.defaultfilters import floatformat
from django.utils.timezone import now
from django_q.models import Task
import logging
logger = logging.getLogger("games")
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):
logger.info(
f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_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=""
)
if purchases.count() == 0:
logger.info("[convert_prices]: No prices to convert.")
for purchase in purchases:
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
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()
logger.info(f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}")
if not exchange_rate:
logger.info(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
)
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
if exchange_rate:
save_converted_info(
purchase,
floatformat(purchase.price * exchange_rate.rate, 0),
currency_to,
)
def calculate_price_per_game():
try:
last_task = Task.objects.filter(group="Update price per game").first()
last_run = last_task.started
except Task.DoesNotExist or AttributeError:
last_run = now()
purchases = Purchase.objects.filter(converted_price__isnull=False).filter(
Q(updated_at__gte=last_run) | Q(price_per_game__isnull=True)
)
logger.info(f"[calculate_price_per_game]: Updating {purchases.count()} purchases.")
purchases.update(
price_per_game=ExpressionWrapper(
F("converted_price") / F("num_purchases"), output_field=FloatField()
)
)

View File

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

View File

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

View File

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

View File

@ -1,5 +1,19 @@
<c-layouts.add>
<c-slot name="additional_row">
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
@ -8,5 +22,21 @@
value="Submit & Create Session" />
</td>
</tr>
</c-slot>
</c-layouts.add>
{% if purchase_id %}
<tr>
<td></td>
<td>
<a href="{% url 'delete_purchase' purchase_id %}"
class="text-red-600"
onclick="return confirm('Are you sure you want to delete this purchase?');">Delete</a>
</td>
</tr>
{% endif %}
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,5 +1,8 @@
<c-layouts.add>
<c-slot name="form_content">
{% extends "base.html" %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
@ -32,5 +35,6 @@
</tr>
</table>
</form>
</c-slot>
</c-layouts.add>
{% load static %}
<script type="module" src="{% static 'js/add_session.js' %}"></script>
{% endblock content %}

View File

@ -7,15 +7,15 @@
<meta name="description" content="Self-hosted time-tracker." />
<meta name="keywords" content="time, tracking, video games, self-hosted" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker - {{ title }}</title>
<title>Timetracker -
{% block title %}
Untitled
{% endblock title %}
</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@ -34,11 +34,16 @@
alt="loading indicator" />
<div class="flex flex-col min-h-screen">
{% include "navbar.html" %}
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">{{ slot }}</div>
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">
{% block content %}
No content here.
{% endblock content %}
</div>
{% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div>
{{ scripts }}
{% block scripts %}
{% endblock scripts %}
<script>
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');

View File

@ -1,6 +1,6 @@
<c-vars color="blue" size="base" type="button" />
<button type="{{ type }}"
<c-vars color="blue" size="base" />
<button type="button"
title="{{ title }}"
class="{{ class }} {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
class=" {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
{{ slot }}
</button>

View File

@ -1,16 +0,0 @@
<span class="relative ml-3 {{class}}">
<span class="rounded-xl w-2 h-2 absolute -left-3 top-2
{% if status == "u" %}
bg-gray-500
{% elif status == "p" %}
bg-orange-400
{% elif status == "f" %}
bg-green-500
{% elif status == "a" %}
bg-red-500
{% elif status == "r" %}
bg-purple-500
{% endif %}
">&nbsp;</span>
{{ slot }}
</span>

View File

@ -1,5 +1,4 @@
<c-vars title="Epic Games Store" />
<c-svg :title=title viewbox="0 0 50 50">
<c-svg title="Epic Games Store" viewbox="0 0 50 50">
<c-slot name="path">
M 10 3 C 6.69 3 4 5.69 4 9 L 4 41.240234 L 25 47.539062 L 46 41.240234 L 46 9 C 46 5.69 43.31 3 40 3 L 10 3 z M 11 8 L 15 8 L 15 11 L 11 11 L 11 18 L 14 18 L 14 21 L 11 21 L 11 28 L 15 28 L 15 31 L 11 31 C 9.34 31 8 29.66 8 28 L 8 11 C 8 9.34 9.34 8 11 8 z M 17 8 L 23 8 C 24.66 8 26 9.34 26 11 L 26 18 C 26 19.66 24.66 21 23 21 L 20 21 L 20 31 L 17 31 L 17 8 z M 28 8 L 31 8 L 31 31 L 28 31 L 28 8 z M 36 8 L 39 8 C 40.66 8 42 9.34 42 11 L 42 15 L 39 15 L 39 11 L 36 11 L 36 28 L 39 28 L 39 24 L 42 24 L 42 28 C 42 29.66 40.66 31 39 31 L 36 31 C 34.34 31 33 29.66 33 28 L 33 11 C 33 9.34 34.34 8 36 8 z M 20 11 L 20 18 L 23 18 L 23 11 L 20 11 z M 9 34 L 13 34 C 13.55 34 14 34.45 14 35 L 14 36 L 13 36 L 13 35.25 C 13 35.11 12.89 35 12.75 35 L 9.25 35 C 9.11 35 9 35.11 9 35.25 L 9 38.75 C 9 38.89 9.11 39 9.25 39 L 12.75 39 C 12.89 39 13 38.89 13 38.75 L 13 38 L 12 38 L 12 37 L 14 37 L 14 39 C 14 39.55 13.55 40 13 40 L 9 40 C 8.45 40 8 39.55 8 39 L 8 35 C 8 34.45 8.45 34 9 34 z M 18 34 L 19 34 L 22 40 L 21 40 L 20.5 39 L 16.5 39 L 16 40 L 15 40 L 18 34 z M 23 34 L 24 34 L 26 38 L 28 34 L 29 34 L 29 40 L 28 40 L 28 36 L 26.5 39 L 25.5 39 L 24 36 L 24 40 L 23 40 L 23 34 z M 30 34 L 35 34 L 35 35 L 31 35 L 31 36.5 L 33 36.5 L 33 37.5 L 31 37.5 L 31 39 L 35 39 L 35 40 L 30 40 L 30 34 z M 37 34 L 41 34 C 41.55 34 42 34.45 42 35 L 42 35.5 L 41 35.5 L 41 35.25 C 41 35.11 40.89 35 40.75 35 L 37.25 35 C 37.11 35 37 35.11 37 35.25 L 37 36.25 C 37 36.39 37.11 36.5 37.25 36.5 L 41 36.5 C 41.55 36.5 42 36.95 42 37.5 L 42 39 C 42 39.55 41.55 40 41 40 L 37 40 C 36.45 40 36 39.55 36 39 L 36 38.5 L 37 38.5 L 37 38.75 C 37 38.89 37.11 39 37.25 39 L 40.75 39 C 40.89 39 41 38.89 41 38.75 L 41 37.75 C 41 37.61 40.89 37.5 40.75 37.5 L 37 37.5 C 36.45 37.5 36 37.05 36 36.5 L 36 35 C 36 34.45 36.45 34 37 34 z M 18.5 35 L 17 38 L 20 38 L 18.5 35 z
</c-slot>

View File

@ -1,6 +0,0 @@
<c-vars title="Emulated" />
<c-svg :title=title viewbox="0 0 48 48">
<c-slot name="path">
M 8.5 5 C 6.0324991 5 4 7.0324991 4 9.5 L 4 30.5 C 4 32.967501 6.0324991 35 8.5 35 L 17 35 L 17 40 L 13.5 40 A 1.50015 1.50015 0 1 0 13.5 43 L 18.253906 43 A 1.50015 1.50015 0 0 0 18.740234 43 L 29.253906 43 A 1.50015 1.50015 0 0 0 29.740234 43 L 34.5 43 A 1.50015 1.50015 0 1 0 34.5 40 L 31 40 L 31 35 L 39.5 35 C 41.967501 35 44 32.967501 44 30.5 L 44 9.5 C 44 7.0324991 41.967501 5 39.5 5 L 8.5 5 z M 8.5 8 L 39.5 8 C 40.346499 8 41 8.6535009 41 9.5 L 41 30.5 C 41 31.346499 40.346499 32 39.5 32 L 29.746094 32 A 1.50015 1.50015 0 0 0 29.259766 32 L 18.746094 32 A 1.50015 1.50015 0 0 0 18.259766 32 L 8.5 32 C 7.6535009 32 7 31.346499 7 30.5 L 7 9.5 C 7 8.6535009 7.6535009 8 8.5 8 z M 17.5 12 C 16.136406 12 15 13.136406 15 14.5 L 15 25.5 C 15 26.863594 16.136406 28 17.5 28 L 30.5 28 C 31.863594 28 33 26.863594 33 25.5 L 33 14.5 C 33 13.136406 31.863594 12 30.5 12 L 17.5 12 z M 18 18 L 30 18 L 30 25 L 18 25 L 18 18 z M 20 35 L 28 35 L 28 40 L 20 40 L 20 35 z
</c-slot>
</c-svg>

View File

@ -1 +0,0 @@
<c-icon.nintendo />

View File

@ -1,6 +0,0 @@
<c-vars title="Nintendo" />
<c-svg viewBox="0 0 24 24">
<c-slot name="path">
M0 .6h7.1l9.85 15.9V.6H24v22.8h-7.04L7.06 7.5v15.9H0V.6
</c-slot>
</c-svg>

View File

@ -1,5 +0,0 @@
<c-svg viewbox="0 0 512 512">
<title>Physical Media</title>
<path fill="currentColor" d="M277.333,256c0-11.755-9.557-21.333-21.333-21.333s-21.333,9.579-21.333,21.333c0,11.755,9.557,21.333,21.333,21.333 S277.333,267.755,277.333,256z" />
<path fill="currentColor" d="M256,0C114.837,0,0,114.837,0,256s114.837,256,256,256s256-114.837,256-256S397.163,0,256,0z M128,256 c0,11.776-9.536,21.333-21.333,21.333c-11.797,0-21.333-9.557-21.333-21.333c0-94.101,76.565-170.667,170.667-170.667 c11.797,0,21.333,9.557,21.333,21.333S267.797,128,256,128C185.408,128,128,185.408,128,256z M192,256c0-35.285,28.715-64,64-64 s64,28.715,64,64s-28.715,64-64,64S192,291.285,192,256z M256,426.667c-11.797,0-21.333-9.557-21.333-21.333S244.203,384,256,384 c70.592,0,128-57.408,128-128c0-11.776,9.536-21.333,21.333-21.333s21.333,9.557,21.333,21.333 C426.667,350.101,350.101,426.667,256,426.667z" />
</c-svg>

View File

@ -1,6 +0,0 @@
<c-vars title="Playstation 1" />
<c-svg viewBox="0 0 50 50">
<c-slot name="path">
M 19.3125 4 C 19.011719 4 18.707031 3.988281 18.40625 4.1875 C 18.105469 4.386719 18 4.699219 18 5 L 18 41.59375 C 18 41.992188 18.289063 42.394531 18.6875 42.59375 L 26.6875 45 L 27 45 C 27.199219 45 27.394531 44.914063 27.59375 44.8125 C 27.894531 44.613281 28 44.300781 28 44 L 28 13.40625 C 28.601563 13.707031 29 14.300781 29 15 L 29 26.09375 C 29 26.394531 29.199219 26.804688 29.5 26.90625 C 29.699219 27.007813 31.199219 27.90625 34 27.90625 C 36.699219 27.90625 40 26.414063 40 19.3125 C 40 13.613281 36.8125 9.292969 31.3125 7.59375 Z M 17 26.40625 L 5.90625 30.40625 L 4.3125 31 C 1.613281 32.101563 0 33.886719 0 35.6875 C 0 39.488281 2.699219 41.6875 7.5 41.6875 C 10.101563 41.6875 13.300781 41.113281 17 39.8125 L 17 36 C 16.101563 36.300781 15.113281 36.699219 14.3125 37 C 12.710938 37.601563 11.5 37.8125 10.5 37.8125 C 9 37.8125 8.300781 37.300781 8 37 C 7.601563 36.699219 7.398438 36.3125 7.5 35.8125 C 7.601563 34.8125 8.800781 33.894531 11 33.09375 C 11.5 32.894531 14.898438 31.699219 17 31 Z M 36.5 28.90625 C 34.101563 29.007813 31.601563 29.394531 29 30.09375 L 29 34.6875 C 30.101563 34.289063 31.585938 33.800781 33.6875 33 C 38.488281 31.300781 40.492188 31.488281 41.09375 31.6875 C 42.292969 31.789063 42.800781 32.5 43 33 C 43.5 34.5 41.613281 35.1875 38.8125 36.1875 C 37.511719 36.6875 31.898438 38.6875 29 39.6875 L 29 44.3125 L 44.5 38.8125 L 45.6875 38.3125 C 47.6875 37.613281 50.199219 36.300781 50 34 C 49.898438 31.800781 47.210938 30.695313 45.3125 30.09375 C 42.511719 29.195313 39.5 28.804688 36.5 28.90625 Z
</c-slot>
</c-svg>

View File

@ -1 +0,0 @@
<c-icon.playstation />

View File

@ -1,24 +0,0 @@
<c-layouts.base>
{% load static %}
{% if form_content %}
{{ form_content }}
{% else %}
<div class="max-width-container">
<div class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_div }}
<div><input type="submit" value="Submit" /></div>
<div class="submit-button-container">
{{ additional_row }}
</div>
</form>
</div>
</div>
{% endif %}
<c-slot name="scripts">
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
</c-slot>
</c-layouts.base>

View File

@ -1,10 +1,8 @@
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span>
<span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
<div data-popover
id="{{ id }}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-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

@ -1 +0,0 @@
<span title="Price is a result of conversion and rounding." class="decoration-dotted underline">{{ slot }}</span>

View File

@ -1,12 +0,0 @@
<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 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden">
<tbody class="dark:divide-y">
{% 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-col md:flex-row md:justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
aria-label="Table navigation">
<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>
<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>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{% if page_obj.has_previous %}

View File

@ -1,16 +0,0 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
<form method="post" class="dark:text-white">
{% csrf_token %}
<div>
<p>Are you sure you want to delete this status change?</p>
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
<a href="{% url 'view_game' object.game.id %}" class="">
<c-button color="gray" class="w-full">Cancel</c-button>
</a>
</div>
</form>
</div>
</c-layouts.base>

View File

@ -1,7 +0,0 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
<c-simple-table :columns=["Test"] :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
</c-layouts.base>

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