40 Commits

Author SHA1 Message Date
89de85c00d Introduce game status, playevents
All checks were successful
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-03-22 20:59:23 +01:00
d892659132 Add debugging config 2025-03-22 20:58:30 +01:00
341e62283b Change gamestatus to non-block element 2025-03-22 20:54:20 +01:00
61b6c1c55f Set default logger to WARNING, add games handler at INFO 2025-03-22 20:52:39 +01:00
eeaa02bada Add helper scripts for getting exchange rates 2025-03-22 20:52:02 +01:00
9d16bc2546 Update exchangerates.yaml 2025-03-22 20:49:21 +01:00
7a52b59b3d Improve logging in tasks.py
All checks were successful
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 2m9s
2025-03-22 16:57:27 +01:00
0ce59a8cc6 Fix a bug in convert_prices
Prevents actually finding any new prices
2025-03-22 16:57:27 +01:00
e0dfc0fc3e update dependencies 2025-03-22 09:14:46 +01:00
8cb67ca002 Add updated_at to Game 2025-03-17 08:36:41 +01:00
be2a01840c Fix != None 2025-03-17 08:35:48 +01:00
612c42ebb7 Standardize blank and null fields in models 2025-03-17 08:35:07 +01:00
e2255a1c85 Update django-cotton to 1.6.0 2025-03-17 08:33:43 +01:00
0b274b4403 Calculate stats for last 7/14 days from manual as well 2025-03-17 08:30:57 +01:00
ddd75f22b0 Allow games to be set to Mastered 2025-03-17 08:26:56 +01:00
843eed64d6 Add search field to game list
All checks were successful
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m1s
2025-02-10 18:20:46 +01:00
50e7efcfae Fix today's playtime stats
All checks were successful
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Successful in 2m0s
2025-02-09 09:00:28 +01:00
3e713a7637 Switch PRAGMA synchronous back to FULL
All checks were successful
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 2m8s
I had some data loss when restarting a container shortly after a database change, let's see if this prevents it.
2025-02-09 08:56:24 +01:00
2d7342c0d5 Fix add/edit session screen
All checks were successful
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m14s
2025-02-08 17:54:03 +01:00
aba9bc994d Add playtime stats to navbar
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m16s
2025-02-08 13:46:56 +01:00
967ff7df07 Fix wrong purchase form field
All checks were successful
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m22s
2025-02-07 20:21:14 +01:00
2ab497fd54 Also theme password fields 2025-02-07 20:20:33 +01:00
34148466c7 Improve forms
All checks were successful
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m10s
2025-02-04 20:09:25 +01:00
b22e185d47 Add status, mastered to Game 2025-02-04 20:09:05 +01:00
b2b69339b3 Improve game choices on the session form 2025-02-04 18:34:59 +01:00
89d1bbdd9e Fix games stats, show all played games instead of top 10
All checks were successful
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m29s
2025-02-01 10:02:23 +01:00
637e3e6493 Specifying name on NameWithIcon now works 2025-02-01 10:01:56 +01:00
d213a3d35d Improve purchase view
All checks were successful
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 2m16s
2025-01-30 17:54:42 +01:00
2f4e16dd54 Improve database concurrency
All checks were successful
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 1m58s
2025-01-30 16:53:30 +01:00
6f62889e92 Improve price information
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m9s
2025-01-30 16:38:13 +01:00
4ec808eeec Improve display when no purchases or sessions exist 2025-01-30 11:56:59 +01:00
69d27958f3 Fix possible server error 2025-01-30 11:56:47 +01:00
4ec1cf5f28 Improve purchase __str__
All checks were successful
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-01-30 11:41:01 +01:00
d936fdc60d Fix currency API endpoint accepting only lowercase currency strings
Signed-off-by: Lukáš Kucharczyk <lukas@kucharczyk.xyz>
2025-01-30 11:40:22 +01:00
2116cfc219 Remove migrations
All checks were successful
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m12s
2025-01-29 22:28:00 +01:00
6bd8271291 Remove Edition
Some checks failed
Django CI/CD / test (push) Failing after 54s
Django CI/CD / build-and-push (push) Has been skipped
2025-01-29 22:05:06 +01:00
e571feadef Add platform to Game 2025-01-29 18:42:13 +01:00
23c1ce1f96 Set Edition related_name to editions 2025-01-29 18:02:17 +01:00
33103daebc Update Django to 5.1.5 and virtualenv to 20.29.1
All checks were successful
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-01-29 13:45:58 +01:00
ba6028e43d Add emulated property to sessions
Some checks failed
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Has been cancelled
2025-01-29 13:43:35 +01:00
101 changed files with 2755 additions and 2206 deletions

26
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
// 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

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

View File

@ -9,7 +9,7 @@ from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate from common.utils import truncate
from games.models import Purchase from games.models import Game, Purchase, Session
HTMLAttribute = tuple[str, str | int | bool] HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str HTMLTag = str
@ -32,7 +32,7 @@ def Component(
attributesList = [f'{name}="{value}"' for name, value in attributes] attributesList = [f'{name}="{value}"' for name, value in attributes]
# make attribute list into a string # make attribute list into a string
# and insert space between tag and attribute list # and insert space between tag and attribute list
attributesBlob = f" {" ".join(attributesList)}" attributesBlob = f" {' '.join(attributesList)}"
tag: str = "" tag: str = ""
if tag_name != "": if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>" tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
@ -188,49 +188,28 @@ def Icon(
return result return result
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
link = reverse("view_game", args=[int(game_id)])
a_content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
),
PopoverTruncated(name),
],
)
return mark_safe(
A(
url=link,
children=[a_content],
),
)
def LinkedPurchase(purchase: Purchase) -> SafeText: def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("view_purchase", args=[int(purchase.id)]) link = reverse("view_purchase", args=[int(purchase.id)])
link_content = "" link_content = ""
popover_content = "" popover_content = ""
edition_count = purchase.editions.count() game_count = purchase.games.count()
popover_if_not_truncated = False popover_if_not_truncated = False
if edition_count == 1: if game_count == 1:
link_content += purchase.editions.first().name link_content += purchase.games.first().name
popover_content = link_content popover_content = link_content
if edition_count > 1: if game_count > 1:
if purchase.name: if purchase.name:
link_content += f"{purchase.name}" link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>" popover_content += f"<h1>{purchase.name}</h1><br>"
else: else:
link_content += f"{edition_count} games" link_content += f"{game_count} games"
popover_if_not_truncated = True popover_if_not_truncated = True
popover_content += f""" popover_content += f"""
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
{"".join(f"<li>{edition.name}</li>" for edition in purchase.editions.all())} {"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul> </ul>
""" """
icon = purchase.platform.icon if edition_count == 1 else "unspecified" icon = purchase.platform.icon if game_count == 1 else "unspecified"
if link_content == "": if link_content == "":
raise ValueError("link_content is empty!!") raise ValueError("link_content is empty!!")
a_content = Div( a_content = Div(
@ -250,19 +229,54 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
return mark_safe(A(url=link, children=[a_content])) return mark_safe(A(url=link, children=[a_content]))
def NameWithPlatformIcon(name: str, platform: str) -> SafeText: def NameWithIcon(
name: str = "",
platform: str = "",
game_id: int = 0,
session_id: int = 0,
purchase_id: int = 0,
linkify: bool = True,
emulated: bool = False,
) -> SafeText:
create_link = False
link = ""
platform = None
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
create_link = True
if session_id:
session = Session.objects.get(pk=session_id)
emulated = session.emulated
game_id = session.game.pk
if purchase_id:
purchase = Purchase.objects.get(pk=purchase_id)
game_id = purchase.games.first().pk
if game_id:
game = Game.objects.get(pk=game_id)
name = name or game.name
platform = game.platform
link = reverse("view_game", args=[int(game_id)])
content = Div( content = Div(
[("class", "inline-flex gap-2 items-center")], [("class", "inline-flex gap-2 items-center")],
[ [
Icon( Icon(
platform.icon, platform.icon,
[("title", platform.name)], [("title", platform.name)],
), )
if platform
else "",
Icon("emulated", [("title", "Emulated")]) if emulated else "",
PopoverTruncated(name), PopoverTruncated(name),
], ],
) )
return mark_safe(content) return mark_safe(
A(
url=link,
children=[content],
)
if create_link
else content,
)
def PurchasePrice(purchase) -> str: def PurchasePrice(purchase) -> str:

View File

@ -44,9 +44,9 @@
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} */ } */
form label { /* form label {
@apply dark:text-slate-400; @apply dark:text-slate-400;
} } */
.responsive-table { .responsive-table {
@apply dark:text-white mx-auto table-fixed; @apply dark:text-white mx-auto table-fixed;
@ -90,37 +90,37 @@ form label {
} }
} }
form input, /* form input,
select, select,
textarea { textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} } */
form input:disabled, form input:disabled,
select:disabled, select:disabled,
textarea:disabled { textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400; @apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed;
} }
.errorlist { .errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px]; @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, form input,
select, select,
textarea { textarea {
width: 300px; width: 300px;
} }
} } */
@media screen and (max-width: 768px) { /* @media screen and (max-width: 768px) {
form input, form input,
select, select,
textarea { textarea {
width: 150px; width: 150px;
} }
} } */
#button-container button { #button-container button {
@apply mx-1; @apply mx-1;
@ -169,3 +169,27 @@ 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

@ -1,5 +1,13 @@
import operator
from dataclasses import dataclass
from datetime import date from datetime import date
from typing import Any, Generator, TypeVar 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
def safe_division(numerator: int | float, denominator: int | float) -> int | float: def safe_division(numerator: int | float, denominator: int | float) -> int | float:
@ -36,7 +44,7 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str: def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return ( return (
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}") (f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length if len(input_string) > length
else input_string else input_string
) )
@ -50,12 +58,12 @@ def truncate(
raise ValueError("Length cannot be shorter than the length of endpart.") raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length: if len(input_string) > max_content_length:
return f"{input_string[:max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}" return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return ( return (
f"{input_string}{endpart}" f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length if len(input_string) + len(endpart) <= length
else f"{input_string[:length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}" else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
) )
@ -81,3 +89,79 @@ def generate_split_ranges(
def format_float_or_int(number: int | float): def format_float_or_int(number: int | float):
return int(number) if float(number).is_integer() else f"{number:03.2f}" 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

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

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

@ -2,7 +2,6 @@ from django.contrib import admin
from games.models import ( from games.models import (
Device, Device,
Edition,
ExchangeRate, ExchangeRate,
Game, Game,
Platform, Platform,
@ -15,6 +14,5 @@ admin.site.register(Game)
admin.site.register(Purchase) admin.site.register(Purchase)
admin.site.register(Platform) admin.site.register(Platform)
admin.site.register(Session) admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device) admin.site.register(Device)
admin.site.register(ExchangeRate) admin.site.register(ExchangeRate)

80
games/api.py Normal file
View File

@ -0,0 +1,80 @@
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,9 +1,10 @@
from datetime import timedelta # from datetime import timedelta
from django.apps import AppConfig from django.apps import AppConfig
from django.core.management import call_command from django.core.management import call_command
from django.db.models.signals import post_migrate from django.db.models.signals import post_migrate
from django.utils.timezone import now
# from django.utils.timezone import now
class GamesConfig(AppConfig): class GamesConfig(AppConfig):
@ -11,20 +12,32 @@ class GamesConfig(AppConfig):
name = "games" name = "games"
def ready(self): def ready(self):
import games.signals # noqa: F401
post_migrate.connect(schedule_tasks, sender=self) post_migrate.connect(schedule_tasks, sender=self)
def schedule_tasks(sender, **kwargs): def schedule_tasks(sender, **kwargs):
from django_q.models import Schedule # from django_q.models import Schedule
from django_q.tasks import schedule # from django_q.tasks import schedule
if not Schedule.objects.filter(name="Update converted prices").exists(): # if not Schedule.objects.filter(name="Update converted prices").exists():
schedule( # schedule(
"games.tasks.convert_prices", # "games.tasks.convert_prices",
name="Update converted prices", # name="Update converted prices",
schedule_type=Schedule.MINUTES, # schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30), # 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 from games.models import ExchangeRate

View File

@ -110,3 +110,395 @@
currency_to: CZK currency_to: CZK
year: 2018 year: 2018
rate: 3.268 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

@ -2,7 +2,7 @@ from django import forms
from django.urls import reverse from django.urls import reverse
from common.utils import safe_getattr from common.utils import safe_getattr
from games.models import Device, Edition, Game, Platform, Purchase, Session from games.models import Device, Game, Platform, PlayEvent, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
@ -11,17 +11,30 @@ custom_datetime_widget = forms.DateTimeInput(
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) 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): class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField( game = SingleGameChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name") queryset=Game.objects.order_by("sort_name"),
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.all(),
widget=forms.Select(attrs={"autofocus": "autofocus"}), widget=forms.Select(attrs={"autofocus": "autofocus"}),
) )
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name")) 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: class Meta:
widgets = { widgets = {
"timestamp_start": custom_datetime_widget, "timestamp_start": custom_datetime_widget,
@ -29,18 +42,27 @@ class SessionForm(forms.ModelForm):
} }
model = Session model = Session
fields = [ fields = [
"purchase", "game",
"timestamp_start", "timestamp_start",
"timestamp_end", "timestamp_end",
"duration_manual", "duration_manual",
"emulated",
"device", "device",
"note", "note",
"mark_as_played",
] ]
def save(self, commit=True):
class EditionChoiceField(forms.ModelMultipleChoiceField): session = super().save(commit=False)
def label_from_instance(self, obj) -> str: if self.cleaned_data.get("mark_as_played"):
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" 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 IncludePlatformSelect(forms.SelectMultiple): class IncludePlatformSelect(forms.SelectMultiple):
@ -56,19 +78,19 @@ class PurchaseForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/> # Automatically update related_purchase <select/>
# to only include purchases of the selected edition. # to only include purchases of the selected game.
related_purchase_by_edition_url = reverse("related_purchase_by_edition") related_purchase_by_game_url = reverse("related_purchase_by_game")
self.fields["editions"].widget.attrs.update( self.fields["games"].widget.attrs.update(
{ {
"hx-trigger": "load, click", "hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url, "hx-get": related_purchase_by_game_url,
"hx-target": "#id_related_purchase", "hx-target": "#id_related_purchase",
"hx-swap": "outerHTML", "hx-swap": "outerHTML",
} }
) )
editions = EditionChoiceField( games = MultipleGameChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
@ -81,17 +103,13 @@ class PurchaseForm(forms.ModelForm):
widgets = { widgets = {
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
"editions", "games",
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished",
"date_dropped",
"infinite", "infinite",
"price", "price",
"price_currency", "price_currency",
@ -138,24 +156,23 @@ class GameModelChoiceField(forms.ModelChoiceField):
return obj.sort_name return obj.sort_name
class EditionForm(forms.ModelForm): class GameForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField( platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False 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: class Meta:
model = Game model = Game
fields = ["name", "sort_name", "year_released", "wikidata"] fields = [
"name",
"sort_name",
"platform",
"original_year_released",
"year_released",
"status",
"mastered",
"wikidata",
]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
@ -175,3 +192,23 @@ class DeviceForm(forms.ModelForm):
model = Device model = Device
fields = ["name", "type"] fields = ["name", "type"]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
class Meta:
model = PlayEvent
fields = [
"game",
"started",
"ended",
"note",
]
widgets = {
"started": custom_date_widget,
"ended": custom_date_widget,
}

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.1 on 2024-10-04 09:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0037_platform_icon'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='price',
field=models.FloatField(default=0),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-09 22:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0038_alter_purchase_price'),
]
operations = [
migrations.AlterField(
model_name='device',
name='type',
field=models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255),
),
]

View File

@ -1,33 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-09 22:39
from django.db import migrations
def update_device_types(apps, schema_editor):
Device = apps.get_model("games", "Device")
# Mapping of short names to long names
type_map = {
"pc": "PC",
"co": "Console",
"ha": "Handheld",
"mo": "Mobile",
"sbc": "Single-board computer",
"un": "Unknown",
}
# Loop through all devices and update the type field
for device in Device.objects.all():
if device.type in type_map:
device.type = type_map[device.type]
device.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0039_alter_device_type"),
]
operations = [
migrations.RunPython(update_device_types),
]

View File

@ -1,36 +0,0 @@
# Generated by Django 5.1.3 on 2024-11-10 15:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0040_migrate_device_types'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='converted_currency',
field=models.CharField(max_length=3, null=True),
),
migrations.AddField(
model_name='purchase',
name='converted_price',
field=models.FloatField(null=True),
),
migrations.CreateModel(
name='ExchangeRate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-07 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0041_purchase_converted_currency_purchase_converted_price_and_more'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='editions_temp',
field=models.ManyToManyField(blank=True, related_name='temp_purchases', to='games.edition'),
),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-07 20:17
from django.db import migrations
def migrate_edition_to_editions_temp(apps, schema_editor):
Purchase = apps.get_model("games", "Purchase")
for purchase in Purchase.objects.all():
if purchase.edition:
print(
f"Migrating Purchase {purchase.id} with Edition {purchase.edition.id}"
)
purchase.editions_temp.add(purchase.edition)
print(purchase.editions_temp.all())
purchase.save()
else:
print(f"No edition found for Purchase {purchase.id}")
class Migration(migrations.Migration):
dependencies = [
("games", "0042_purchase_editions_temp"),
]
operations = [
migrations.RunPython(migrate_edition_to_editions_temp),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-07 20:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0043_auto_20250107_2117"),
]
operations = [
migrations.RemoveField(model_name="purchase", name="edition"),
migrations.RenameField(
model_name="purchase",
old_name="editions_temp",
new_name="editions",
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-07 20:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0044_auto_20250107_2132'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='editions',
field=models.ManyToManyField(blank=True, related_name='purchases', to='games.edition'),
),
]

View File

@ -1,20 +1,61 @@
import logging
from datetime import timedelta from datetime import timedelta
import requests
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Sum from django.db.models import F, Sum
from django.template.defaultfilters import slugify 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.utils import timezone from django.utils import timezone
from common.time import format_duration from common.time import format_duration
logger = logging.getLogger("games")
class Game(models.Model): class Game(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) sort_name = models.CharField(max_length=255, blank=True, default="")
year_released = models.IntegerField(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) 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
)
created_at = models.DateTimeField(auto_now_add=True) 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_average: float | int | timedelta | None
session_count: int | None session_count: int | None
@ -22,10 +63,36 @@ class Game(models.Model):
def __str__(self): def __str__(self):
return self.name 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 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): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255, blank=True, default="")
icon = models.SlugField(blank=True) icon = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@ -38,35 +105,6 @@ class Platform(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
game = models.ForeignKey(Game, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
Platform, on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet): class PurchaseQueryset(models.QuerySet):
def refunded(self): def refunded(self):
return self.filter(date_refunded__isnull=False) return self.filter(date_refunded__isnull=False)
@ -74,9 +112,6 @@ class PurchaseQueryset(models.QuerySet):
def not_refunded(self): def not_refunded(self):
return self.filter(date_refunded__isnull=True) return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self): def games_only(self):
return self.filter(type=Purchase.GAME) return self.filter(type=Purchase.GAME)
@ -113,54 +148,90 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager() objects = PurchaseQueryset().as_manager()
editions = models.ManyToManyField(Edition, related_name="purchases", blank=True) games = models.ManyToManyField(Game, related_name="purchases")
platform = models.ForeignKey( platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
) )
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True) # move date_finished to PlayEvent model's Finished field
date_dropped = models.DateField(blank=True, null=True) # also set Game's model Status field to Finished
# date_finished = models.DateField(blank=True, null=True)
# move date_dropped to Game model's field Status (Abandoned)
# date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False) infinite = models.BooleanField(default=False)
price = models.FloatField(default=0) price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True) converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, 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( ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True) name = models.CharField(max_length=255, blank=True, default="")
related_purchase = models.ForeignKey( related_purchase = models.ForeignKey(
"self", "self",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
default=None, default=None,
null=True, null=True,
blank=True,
related_name="related_purchases", related_name="related_purchases",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property @property
def first_edition(self): def standardized_price(self):
return self.editions.first() 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): def __str__(self):
return self.standardized_name
@property
def full_name(self):
additional_info = [ additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "", str(item)
( for item in [
f"{self.first_edition.platform} version on {self.platform}" f"{self.num_purchases} game{pluralize(self.num_purchases)}",
if self.platform != self.first_edition.platform self.date_purchased,
else self.platform self.standardized_price,
),
self.first_edition.year_released,
self.get_ownership_type_display(),
] ]
return f"{self.first_edition} ({', '.join(filter(None, map(str, additional_info)))})" if item
]
return f"{self.standardized_name} ({', '.join(additional_info)})"
def is_game(self): def is_game(self):
return self.type == self.GAME 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): def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase: if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError( raise ValidationError(
@ -170,12 +241,15 @@ class Purchase(models.Model):
# Retrieve the existing instance from the database # Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk) existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields # If price has changed, reset converted fields
if ( if existing_purchase.price_or_currency_differ_from(self):
existing_purchase.price != self.price from games.tasks import currency_to
or existing_purchase.price_currency != self.price_currency
): exchange_rate = get_or_create_rate(
self.converted_price = None self.price_currency, currency_to, self.date_purchased.year
self.converted_currency = None )
if exchange_rate:
self.converted_price = floatformat(self.price * exchange_rate, 0)
self.converted_currency = currency_to
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -207,7 +281,13 @@ class Session(models.Model):
class Meta: class Meta:
get_latest_by = "timestamp_start" get_latest_by = "timestamp_start"
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE) game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
null=True,
default=None,
related_name="sessions",
)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
@ -219,7 +299,9 @@ class Session(models.Model):
blank=True, blank=True,
default=None, default=None,
) )
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, default="")
emulated = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True) modified_at = models.DateTimeField(auto_now=True)
@ -227,7 +309,7 @@ class Session(models.Model):
def __str__(self): def __str__(self):
mark = ", manual" if self.is_manual() else "" mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = timezone.now() self.timestamp_end = timezone.now()
@ -240,7 +322,7 @@ class Session(models.Model):
calculated = timedelta(0) calculated = timedelta(0)
if self.is_manual() and isinstance(self.duration_manual, timedelta): if self.is_manual() and isinstance(self.duration_manual, timedelta):
manual = self.duration_manual manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None: if self.timestamp_end is not None and self.timestamp_start is not None:
calculated = self.timestamp_end - self.timestamp_start calculated = self.timestamp_end - self.timestamp_start
return timedelta(seconds=(manual + calculated).total_seconds()) return timedelta(seconds=(manual + calculated).total_seconds())
@ -256,7 +338,7 @@ class Session(models.Model):
return Session.objects.all().total_duration_formatted() return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None:
if self.timestamp_start != None and self.timestamp_end != None: if self.timestamp_start is not None and self.timestamp_end is not None:
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
@ -306,3 +388,97 @@ class ExchangeRate(models.Model):
def __str__(self): def __str__(self):
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})" 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"]

40
games/signals.py Normal file
View File

@ -0,0 +1,40 @@
from django.db.models.signals import m2m_changed, pre_save
from django.dispatch import receiver
from django.utils.timezone import now
from games.models import Game, GameStatusChange, Purchase
@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(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
print("Got old instance")
except sender.DoesNotExist:
# Handle the case where the instance was deleted before the signal was sent
print("Instance does not exist")
return
if old_status != instance.status:
print("Status changed")
GameStatusChange.objects.create(
game=instance,
old_status=old_status,
new_status=instance.status,
timestamp=instance.updated_at,
)
else:
print("Status not changed")
print(f"{old_instance.status}")
print(f"{instance.status}")

View File

@ -1299,6 +1299,14 @@ input:checked + .toggle-bg {
bottom: 0px; bottom: 0px;
} }
.-left-3 {
left: -0.75rem;
}
.-left-\[1px\] {
left: -1px;
}
.bottom-0 { .bottom-0 {
bottom: 0px; bottom: 0px;
} }
@ -1335,10 +1343,18 @@ input:checked + .toggle-bg {
top: 0px; top: 0px;
} }
.top-2 {
top: 0.5rem;
}
.top-3 { .top-3 {
top: 0.75rem; top: 0.75rem;
} }
.top-\[100\%\] {
top: 100%;
}
.z-10 { .z-10 {
z-index: 10; z-index: 10;
} }
@ -1415,6 +1431,10 @@ input:checked + .toggle-bg {
margin-inline-end: 0.5rem; margin-inline-end: 0.5rem;
} }
.ml-3 {
margin-left: 0.75rem;
}
.mr-4 { .mr-4 {
margin-right: 1rem; margin-right: 1rem;
} }
@ -1443,10 +1463,6 @@ input:checked + .toggle-bg {
margin-top: 1rem; margin-top: 1rem;
} }
.ml-4 {
margin-left: 1rem;
}
.block { .block {
display: block; display: block;
} }
@ -1475,10 +1491,6 @@ input:checked + .toggle-bg {
display: grid; display: grid;
} }
.list-item {
display: list-item;
}
.hidden { .hidden {
display: none; display: none;
} }
@ -1496,6 +1508,10 @@ input:checked + .toggle-bg {
height: 3rem; height: 3rem;
} }
.h-2 {
height: 0.5rem;
}
.h-2\.5 { .h-2\.5 {
height: 0.625rem; height: 0.625rem;
} }
@ -1532,6 +1548,10 @@ input:checked + .toggle-bg {
width: 2.5rem; width: 2.5rem;
} }
.w-2 {
width: 0.5rem;
}
.w-2\.5 { .w-2\.5 {
width: 0.625rem; width: 0.625rem;
} }
@ -1540,6 +1560,10 @@ input:checked + .toggle-bg {
width: 6rem; width: 6rem;
} }
.w-3 {
width: 0.75rem;
}
.w-4 { .w-4 {
width: 1rem; width: 1rem;
} }
@ -1564,6 +1588,10 @@ input:checked + .toggle-bg {
width: 20rem; width: 20rem;
} }
.w-auto {
width: auto;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@ -1584,6 +1612,10 @@ input:checked + .toggle-bg {
max-width: 24rem; max-width: 24rem;
} }
.max-w-xl {
max-width: 36rem;
}
.max-w-xs { .max-w-xs {
max-width: 20rem; max-width: 20rem;
} }
@ -1712,6 +1744,10 @@ input:checked + .toggle-bg {
justify-content: space-between; justify-content: space-between;
} }
.gap-1 {
gap: 0.25rem;
}
.gap-2 { .gap-2 {
gap: 0.5rem; gap: 0.5rem;
} }
@ -1724,6 +1760,10 @@ input:checked + .toggle-bg {
gap: 1.25rem; gap: 1.25rem;
} }
.gap-y-4 {
row-gap: 1rem;
}
.-space-x-px > :not([hidden]) ~ :not([hidden]) { .-space-x-px > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
margin-right: calc(-1px * var(--tw-space-x-reverse)); margin-right: calc(-1px * var(--tw-space-x-reverse));
@ -1795,6 +1835,15 @@ input:checked + .toggle-bg {
border-radius: 0.125rem; 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 { .rounded-e-lg {
border-start-end-radius: 0.5rem; border-start-end-radius: 0.5rem;
border-end-end-radius: 0.5rem; border-end-end-radius: 0.5rem;
@ -1815,6 +1864,14 @@ input:checked + .toggle-bg {
border-end-start-radius: 0.5rem; 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 {
border-width: 1px; border-width: 1px;
} }
@ -1823,6 +1880,18 @@ input:checked + .toggle-bg {
border-width: 0px; 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 { .border-blue-600 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(28 100 242 / var(--tw-border-opacity)); border-color: rgb(28 100 242 / var(--tw-border-opacity));
@ -1888,15 +1957,29 @@ input:checked + .toggle-bg {
background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 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 { .bg-gray-800 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); 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 { .bg-gray-900\/50 {
background-color: rgb(17 24 39 / 0.5); 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 { .bg-green-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(5 122 85 / var(--tw-bg-opacity)); background-color: rgb(5 122 85 / var(--tw-bg-opacity));
@ -1907,6 +1990,21 @@ input:checked + .toggle-bg {
background-color: rgb(4 108 78 / var(--tw-bg-opacity)); 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 { .bg-red-700 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(200 30 30 / var(--tw-bg-opacity)); background-color: rgb(200 30 30 / var(--tw-bg-opacity));
@ -2038,6 +2136,10 @@ input:checked + .toggle-bg {
vertical-align: top; vertical-align: top;
} }
.align-middle {
vertical-align: middle;
}
.font-mono { .font-mono {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
} }
@ -2185,6 +2287,11 @@ input:checked + .toggle-bg {
color: rgb(203 213 225 / var(--tw-text-opacity)); 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 { .text-slate-500 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity)); color: rgb(100 116 139 / var(--tw-text-opacity));
@ -2257,6 +2364,12 @@ 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); 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 {
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, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
@ -2357,10 +2470,9 @@ input:checked + .toggle-bg {
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} */ } */
form label:is(.dark *) { /* form label {
--tw-text-opacity: 1; @apply dark:text-slate-400;
color: rgb(148 163 184 / var(--tw-text-opacity)); } */
}
.responsive-table { .responsive-table {
margin-left: auto; margin-left: auto;
@ -2399,25 +2511,25 @@ form label:is(.dark *) {
border-left-color: rgb(100 116 139 / var(--tw-border-opacity)); border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
form input:is(.dark *), /* form input,
select:is(.dark *), select,
textarea:is(.dark *) { textarea {
border-width: 1px; @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
--tw-border-opacity: 1; } */
border-color: rgb(15 23 42 / var(--tw-border-opacity));
--tw-bg-opacity: 1; form input:disabled,
background-color: rgb(100 116 139 / var(--tw-bg-opacity)); select:disabled,
--tw-text-opacity: 1; textarea:disabled {
color: rgb(241 245 249 / var(--tw-text-opacity)); cursor: not-allowed;
} }
form input:disabled:is(.dark *), form input:disabled:is(.dark *),
select:disabled:is(.dark *), select:disabled:is(.dark *),
textarea:disabled:is(.dark *) { textarea:disabled:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity)); background-color: rgb(30 41 59 / var(--tw-bg-opacity));
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(100 116 139 / var(--tw-text-opacity));
} }
.errorlist { .errorlist {
@ -2433,21 +2545,21 @@ textarea:disabled:is(.dark *) {
color: rgb(226 232 240 / var(--tw-text-opacity)); color: rgb(226 232 240 / var(--tw-text-opacity));
} }
@media screen and (min-width: 768px) { /* @media screen and (min-width: 768px) {
form input, form input,
select, select,
textarea { textarea {
width: 300px; width: 300px;
} }
} } */
@media screen and (max-width: 768px) { /* @media screen and (max-width: 768px) {
form input, form input,
select, select,
textarea { textarea {
width: 150px; width: 150px;
} }
} } */
#button-container button { #button-container button {
margin-left: 0.25rem; margin-left: 0.25rem;
@ -2556,6 +2668,47 @@ 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) { .odd\:bg-white:nth-child(odd) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));

View File

@ -7,7 +7,7 @@ import {
let syncData = [ let syncData = [
{ {
source: "#id_edition", source: "#id_games",
source_value: "dataset.platform", source_value: "dataset.platform",
target: "#id_platform", target: "#id_platform",
target_value: "value", target_value: "value",
@ -21,11 +21,6 @@ function setupElementHandlers() {
"#id_name", "#id_name",
"#id_related_purchase", "#id_related_purchase",
]); ]);
disableElementsWhenValueNotEqual(
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers); document.addEventListener("DOMContentLoaded", setupElementHandlers);
@ -36,8 +31,8 @@ getEl("#id_type").onchange = () => {
document.body.addEventListener("htmx:beforeRequest", function (event) { document.body.addEventListener("htmx:beforeRequest", function (event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request // Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_edition") { if (event.target.id === "id_games") {
var idEditionValue = document.getElementById("id_edition").value; var idEditionValue = document.getElementById("id_games").value;
// Condition to check - replace this with your actual logic // Condition to check - replace this with your actual logic
if (idEditionValue != "") { if (idEditionValue != "") {

View File

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

View File

@ -1,4 +1,11 @@
import requests 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 from games.models import ExchangeRate, Purchase
@ -8,8 +15,8 @@ currency_to = currency_to.upper()
def save_converted_info(purchase, converted_price, converted_currency): def save_converted_info(purchase, converted_price, converted_currency):
print( logger.info(
f"Changing converted price of {purchase} to {converted_price} {converted_currency} " f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})"
) )
purchase.converted_price = converted_price purchase.converted_price = converted_price
purchase.converted_currency = converted_currency purchase.converted_currency = converted_currency
@ -18,8 +25,10 @@ def save_converted_info(purchase, converted_price, converted_currency):
def convert_prices(): def convert_prices():
purchases = Purchase.objects.filter( purchases = Purchase.objects.filter(
converted_price__isnull=True, converted_currency__isnull=True converted_price__isnull=True, converted_currency=""
) )
if purchases.count() == 0:
logger.info("[convert_prices]: No prices to convert.")
for purchase in purchases: for purchase in purchases:
if purchase.price_currency.upper() == currency_to or purchase.price == 0: if purchase.price_currency.upper() == currency_to or purchase.price == 0:
@ -27,31 +36,59 @@ def convert_prices():
continue continue
year = purchase.date_purchased.year year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper() currency_from = purchase.price_currency.upper()
exchange_rate = ExchangeRate.objects.filter( exchange_rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year currency_from=currency_from, currency_to=currency_to, year=year
).first() ).first()
logger.info(f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}")
if not exchange_rate: if not exchange_rate:
logger.info(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
)
try: try:
# this API endpoint only accepts lowercase currency string
response = requests.get( response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from}.json" f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
rate = data[currency_from].get(currency_to) currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate: if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create( exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from, currency_from=currency_from,
currency_to=currency_to, currency_to=currency_to,
year=year, year=year,
rate=rate, rate=floatformat(rate, 2),
) )
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e: except requests.RequestException as e:
print( logger.info(
f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
) )
if exchange_rate: if exchange_rate:
save_converted_info( save_converted_info(
purchase, purchase.price * exchange_rate.rate, currency_to 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,12 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</c-slot>
</c-layouts.add>

View File

@ -1,12 +1,7 @@
<c-layouts.add> <c-layouts.add>
<c-slot name="additional_row"> <c-slot name="additional_row">
<tr>
<td></td>
<td>
<input type="submit" <input type="submit"
name="submit_and_redirect" name="submit_and_redirect"
value="Submit & Create Edition" /> value="Submit & Create Purchase" />
</td>
</tr>
</c-slot> </c-slot>
</c-layouts.add> </c-layouts.add>

View File

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

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

View File

@ -3,19 +3,18 @@
{% if form_content %} {% if form_content %}
{{ form_content }} {{ form_content }}
{% else %} {% else %}
<div class="max-width-container">
<div class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }} {{ form.as_div }}
<tr> <div><input type="submit" value="Submit" /></div>
<td></td> <div class="submit-button-container">
<td>
<input type="submit" value="Submit" />
</td>
</tr>
{{ additional_row }} {{ additional_row }}
</table> </div>
</form> </form>
</div>
</div>
{% endif %} {% endif %}
<c-slot name="scripts"> <c-slot name="scripts">
{% if script_name %} {% if script_name %}

View File

@ -12,6 +12,10 @@
{% django_htmx_script %} {% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script> <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> <script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC // 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)) { if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {

View File

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

View File

@ -0,0 +1,6 @@
<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=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
</c-layouts.base>

View File

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

View File

@ -25,7 +25,11 @@
</svg> </svg>
</button> </button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown"> <div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
<ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"> <ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li class="text-white flex flex-col items-center text-xs">
<span class="flex uppercase gap-1">Today<span class="text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{{ today_played }}<span class="text-gray-400">·</span>{{ last_7_played }}</span>
</li>
<li> <li>
<a href="#" <a href="#"
class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
@ -57,10 +61,6 @@
<a href="{% url 'add_game' %}" <a href="{% url 'add_game' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
</li> </li>
<li>
<a href="{% url 'add_edition' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Edition</a>
</li>
<li> <li>
<a href="{% url 'add_platform' %}" <a href="{% url 'add_platform' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
@ -102,14 +102,14 @@
<a href="{% url 'list_games' %}" <a href="{% url 'list_games' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
</li> </li>
<li>
<a href="{% url 'list_editions' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a>
</li>
<li> <li>
<a href="{% url 'list_platforms' %}" <a href="{% url 'list_platforms' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
</li> </li>
<li>
<a href="{% url 'list_playevents' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
</li>
<li> <li>
<a href="{% url 'list_purchases' %}" <a href="{% url 'list_purchases' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>

View File

@ -2,11 +2,11 @@
{% load static %} {% load static %}
{% partialdef purchase-name %} {% partialdef purchase-name %}
{% if purchase.type != 'game' %} {% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.first_edition.game.id> <c-gamelink :game_id=purchase.first_game.id>
{{ purchase.name }} ({{ purchase.first_edition.edition.name }} {{ purchase.get_type_display }}) {{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
</c-gamelink> </c-gamelink>
{% else %} {% else %}
<c-gamelink :game_id=purchase.first_edition.game.id :name=purchase.first_edition.name /> <c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
{% endif %} {% endif %}
{% endpartialdef %} {% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
@ -46,7 +46,7 @@
{% endif %} {% endif %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_year_games }}</td>
</tr> </tr>
{% if all_finished_this_year_count %} {% if all_finished_this_year_count %}
<tr> <tr>
@ -148,7 +148,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1> <h1 class="text-5xl text-center my-6">Games by playtime</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
<tr> <tr>
@ -196,7 +196,7 @@
<tbody> <tbody>
{% for purchase in all_finished_this_year %} {% for purchase in all_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.game_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -209,14 +209,14 @@
<thead> <thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for purchase in this_year_finished_this_year %} {% for purchase in this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% comment %} <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% endcomment %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -228,14 +228,14 @@
<thead> <thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for purchase in purchased_this_year_finished_this_year %} {% for purchase in purchased_this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% comment %} <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% endcomment %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -52,6 +52,68 @@
{{ playrange }} {{ playrange }}
</c-popover> </c-popover>
</div> </div>
<div class="flex flex-col mb-6 text-slate-400 gap-y-4">
<div class="flex gap-2 items-center">
<span class="uppercase">Original year</span>
<span class="text-slate-300">{{ game.original_year_released }}</span>
</div>
<div class="flex gap-2 items-center">
<span class="uppercase">Status</span>
<c-gamestatus :status="game.status" class="text-slate-300">
{{ game.get_status_display }}
</c-gamestatus>
{% if game.mastered %}👑{% endif %}
</div>
<div class="flex gap-2 items-center"
x-data="{ open: false }"
>
<span class="uppercase">Played</span>
<div class="inline-flex rounded-md shadow-xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
<a href="{% url 'add_playevent' %}">
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
<span x-text="played"></span> times
</button>
</a>
<button type="button" x-on:click="open = !open" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle">
<svg class="text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div
class="absolute top-[100%] -left-[1px] w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="{% url 'add_playevent_for_game' game.id %}">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
fetch('{% url 'api-1.0.0:create_playevent' %}', { method: 'POST', headers: { 'X-CSRFToken': '{{ csrf_token }}' }, body: '{"game_id": {{ game.id }}}'})
}
</script>
</ul>
</div>
</button>
</div>
</div>
<div class="flex gap-2 items-center">
<span class="uppercase">Platform</span>
<span class="text-slate-300">{{ game.platform }}</span>
</div>
</div>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group"> <div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_game' game.id %}"> <a href="{% url 'edit_game' game.id %}">
<button type="button" <button type="button"
@ -67,17 +129,39 @@
</a> </a>
</div> </div>
</div> </div>
<c-h1 :badge="edition_count">Editions</c-h1>
<div class="mb-6">
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
</div>
<div class="mb-6"> <div class="mb-6">
<c-h1 :badge="purchase_count">Purchases</c-h1> <c-h1 :badge="purchase_count">Purchases</c-h1>
{% if purchase_count %}
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns /> <c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />
{% else %}
No purchases yet.
{% endif %}
</div> </div>
<div class="mb-6"> <div class="mb-6">
<c-h1 :badge="session_count">Sessions</c-h1> <c-h1 :badge="session_count">Sessions</c-h1>
{% if session_count %}
<c-simple-table :rows=session_data.rows :columns=session_data.columns :header_action=session_data.header_action :page_obj=session_page_obj :elided_page_range=session_elided_page_range /> <c-simple-table :rows=session_data.rows :columns=session_data.columns :header_action=session_data.header_action :page_obj=session_page_obj :elided_page_range=session_elided_page_range />
{% else %}
No sessions yet.
{% endif %}
</div>
<!-- list all playevents -->
<div class="mb-6">
<c-h1 :badge="playevent_count">Play Events</c-h1>
{% if playevent_count %}
<c-simple-table :rows=playevent_data.rows :columns=playevent_data.columns />
{% else %}
No play events yet.
{% endif %}
</div>
<div class="mb-6">
<c-h1 :badge="statuschange_count">History</c-h1>
<ul class="list-disc list-inside">
{% for change in statuschanges %}
<li class="text-slate-500">
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status" class="text-white">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status" class="text-white">{{ change.get_new_status_display }}</c-gamestatus></li>
{% endfor %}
</ul>
</div> </div>
</div> </div>
<script> <script>

View File

@ -2,8 +2,17 @@
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex flex-col gap-5 mb-3"> <div class="flex flex-col gap-5 mb-3">
<div class="font-bold font-serif text-slate-500 text-2xl">
{% if not purchase.name %}
Unnamed purchase
{% else %}
{{ purchase.name }}
{% endif %}
</div>
<span class="text-balance max-w-[30rem] text-4xl"> <span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{% if purchase.name %}{{ purchase.name }}{% else %}Unnamed purchase{% endif %}</span> <span class="text-slate-500 text-2xl">({{ purchase.editions.count }} games)</span> <span class="font-bold font-serif">
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
</span>
</span> </span>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group"> <div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_purchase' purchase.id %}"> <a href="{% url 'edit_purchase' purchase.id %}">
@ -19,12 +28,19 @@
</button> </button>
</a> </a>
</div> </div>
<div>Price: {{ purchase.converted_price | floatformat }} {{ purchase.converted_currency }} ({{ purchase.price | floatformat }} {{ purchase.price_currency }})</div> <div>
<p>
Price:
<c-price-converted>{{ purchase.standardized_price }}</c-price-converted>
({{ purchase.price | floatformat:2 }} {{ purchase.price_currency }})
</p>
<p>Price per game: <c-price-converted>{{ purchase.price_per_game | floatformat:0 }} {{ purchase.converted_currency }}</c-price-converted> </p>
</div>
<div> <div>
<h2 class="text-base">Items:</h2> <h2 class="text-base">Items:</h2>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
{% for edition in purchase.editions.all %} {% for game in purchase.games.all %}
<li><c-gamelink :game_id=edition.game.id :name=edition.name /></li> <li><c-gamelink :game_id=game.id :name=game.name /></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from games.views import device, edition, game, general, platform, purchase, session from games.api import api
from games.views import device, game, general, platform, playevent, purchase, session
urlpatterns = [ urlpatterns = [
path("", general.index, name="index"), path("", general.index, name="index"),
@ -8,19 +9,6 @@ urlpatterns = [
path("device/delete/<int:device_id>", device.delete_device, name="delete_device"), path("device/delete/<int:device_id>", device.delete_device, name="delete_device"),
path("device/edit/<int:device_id>", device.edit_device, name="edit_device"), path("device/edit/<int:device_id>", device.edit_device, name="edit_device"),
path("device/list", device.list_devices, name="list_devices"), path("device/list", device.list_devices, name="list_devices"),
path("edition/add", edition.add_edition, name="add_edition"),
path(
"edition/add/for-game/<int:game_id>",
edition.add_edition,
name="add_edition_for_game",
),
path("edition/<int:edition_id>/edit", edition.edit_edition, name="edit_edition"),
path("edition/list", edition.list_editions, name="list_editions"),
path(
"edition/<int:edition_id>/delete",
edition.delete_edition,
name="delete_edition",
),
path("game/add", game.add_game, name="add_game"), path("game/add", game.add_game, name="add_game"),
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"), path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
path("game/<int:game_id>/view", game.view_game, name="view_game"), path("game/<int:game_id>/view", game.view_game, name="view_game"),
@ -38,7 +26,29 @@ urlpatterns = [
name="delete_platform", name="delete_platform",
), ),
path("platform/list", platform.list_platforms, name="list_platforms"), path("platform/list", platform.list_platforms, name="list_platforms"),
path("playevent/list", playevent.list_playevents, name="list_playevents"),
path("playevent/add", playevent.add_playevent, name="add_playevent"),
path(
"playevent/add/for-game/<int:game_id>",
playevent.add_playevent,
name="add_playevent_for_game",
),
path(
"playevent/edit/<int:playevent_id>",
playevent.edit_playevent,
name="edit_playevent",
),
path(
"playevent/delete/<int:playevent_id>",
playevent.delete_playevent,
name="delete_playevent",
),
path("purchase/add", purchase.add_purchase, name="add_purchase"), path("purchase/add", purchase.add_purchase, name="add_purchase"),
path(
"purchase/add/for-game/<int:game_id>",
purchase.add_purchase,
name="add_purchase_for_game",
),
path( path(
"purchase/<int:purchase_id>/edit", "purchase/<int:purchase_id>/edit",
purchase.edit_purchase, purchase.edit_purchase,
@ -75,20 +85,15 @@ urlpatterns = [
name="refund_purchase", name="refund_purchase",
), ),
path( path(
"purchase/related-purchase-by-edition", "purchase/related-purchase-by-game",
purchase.related_purchase_by_edition, purchase.related_purchase_by_game,
name="related_purchase_by_edition", name="related_purchase_by_game",
),
path(
"purchase/add/for-edition/<int:edition_id>",
purchase.add_purchase,
name="add_purchase_for_edition",
), ),
path("session/add", session.add_session, name="add_session"), path("session/add", session.add_session, name="add_session"),
path( path(
"session/add/for-purchase/<int:purchase_id>", "session/add/for-game/<int:game_id>",
session.add_session, session.add_session,
name="add_session_for_purchase", name="add_session_for_game",
), ),
path( path(
"session/add/from-game/<int:session_id>", "session/add/from-game/<int:session_id>",
@ -128,4 +133,5 @@ urlpatterns = [
general.stats, general.stats,
name="stats_by_year", name="stats_by_year",
), ),
path("api/", api.urls),
] ]

View File

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

View File

@ -2,7 +2,7 @@ from typing import Any
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Prefetch from django.db.models import Prefetch, Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string from django.template.loader import render_to_string
@ -12,9 +12,10 @@ from common.components import (
A, A,
Button, Button,
Div, Div,
Form,
Icon, Icon,
LinkedPurchase, LinkedPurchase,
NameWithPlatformIcon, NameWithIcon,
Popover, Popover,
PopoverTruncated, PopoverTruncated,
PurchasePrice, PurchasePrice,
@ -27,19 +28,40 @@ from common.time import (
local_strftime, local_strftime,
timeformat, timeformat,
) )
from common.utils import safe_division, truncate from common.utils import build_dynamic_filter, safe_division, truncate
from games.forms import GameForm from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
from games.views.playevent import create_playevent_tabledata
@login_required @login_required
def list_games(request: HttpRequest) -> HttpResponse: def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
context: dict[Any, Any] = {} context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1) page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10) limit = request.GET.get("limit", 10)
games = Game.objects.order_by("-created_at") games = Game.objects.order_by("-created_at")
page_obj = None page_obj = None
search_string = request.GET.get("search_string", search_string)
if search_string != "":
filters = [
Q(name__icontains=search_string),
Q(sort_name__icontains=search_string),
Q(platform__name__icontains=search_string),
]
try:
year_value = int(search_string)
except ValueError:
year_value = None
if year_value:
filters.append(Q(year_released=year_value))
search_string_parts = search_string.split()
# only search for status if it exactly matches and is the only word
if len(search_string_parts) == 1:
if search_string.title() in Game.Status.labels:
search_status = Game.Status[search_string.upper()]
filters.append(Q(status=search_status))
games = games.filter(build_dynamic_filter(filters, "|"))
if int(limit) != 0: if int(limit) != 0:
paginator = Paginator(games, limit) paginator = Paginator(games, limit)
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
@ -56,35 +78,45 @@ def list_games(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add game"), url="add_game"), "header_action": Div(
children=[
Form(
children=[
render_to_string(
"cotton/search_field.html",
{
"id": "search_string",
"search_string": search_string,
},
)
]
),
A([], Button([], "Add game"), url="add_game"),
],
attributes=[("class", "flex justify-between")],
),
"columns": [ "columns": [
"Name", "Name",
"Sort Name", "Sort Name",
"Year", "Year",
"Status",
"Wikidata", "Wikidata",
"Created", "Created",
"Actions", "Actions",
], ],
"rows": [ "rows": [
[ [
A( NameWithIcon(game_id=game.pk),
[
(
"href",
reverse(
"view_game",
args=[game.pk],
),
)
],
PopoverTruncated(game.name),
),
PopoverTruncated( PopoverTruncated(
game.sort_name game.sort_name
if game.sort_name is not None and game.name != game.sort_name if game.sort_name is not None and game.name != game.sort_name
else "(identical)" else "(identical)"
), ),
game.year_released, game.year_released,
render_to_string(
"cotton/gamestatus.html",
{"status": game.status, "slot": game.get_status_display()},
),
game.wikidata, game.wikidata,
local_strftime(game.created_at, dateformat), local_strftime(game.created_at, dateformat),
render_to_string( render_to_string(
@ -120,7 +152,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
game = form.save() game = form.save()
if "submit_and_redirect" in request.POST: if "submit_and_redirect" in request.POST:
return HttpResponseRedirect( return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id}) reverse("add_purchase_for_game", kwargs={"game_id": game.id})
) )
else: else:
return redirect("list_games") return redirect("list_games")
@ -169,23 +201,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
), ),
to_attr="game_purchases", to_attr="game_purchases",
) )
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
purchases = Purchase.objects.filter(editions__game=game).order_by("date_purchased") purchases = game.purchases.order_by("date_purchased")
sessions = Session.objects.prefetch_related("device").filter( sessions = game.sessions
purchase__editions__game=game
)
session_count = sessions.count() session_count = sessions.count()
session_count_without_manual = ( session_count_without_manual = game.sessions.without_manual().count()
Session.objects.without_manual().filter(purchase__editions__game=game).count()
)
if sessions: if sessions.exists():
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y") playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
latest_session = sessions.latest() latest_session = sessions.latest()
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y") playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
@ -204,41 +227,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
format_duration(sessions.calculated_duration_unformatted(), "%2.1H") format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
) )
edition_data: dict[str, Any] = {
"columns": [
"Name",
"Year Released",
"Actions",
],
"rows": [
[
NameWithPlatformIcon(
name=edition.name,
platform=edition.platform,
),
edition.year_released,
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("delete_edition", args=[edition.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for edition in editions
],
}
purchase_data: dict[str, Any] = { purchase_data: dict[str, Any] = {
"columns": ["Name", "Type", "Date", "Price", "Actions"], "columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [ "rows": [
@ -269,9 +257,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
], ],
} }
sessions_all = Session.objects.filter(purchase__editions__game=game).order_by( sessions_all = game.sessions.order_by("-timestamp_start")
"-timestamp_start"
)
last_session = None last_session = None
if sessions_all.exists(): if sessions_all.exists():
last_session = sessions_all.latest() last_session = sessions_all.latest()
@ -298,7 +285,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
args=[last_session.pk], args=[last_session.pk],
), ),
children=Popover( children=Popover(
popover_content=last_session.purchase.first_edition.name, popover_content=last_session.game.name,
children=[ children=[
Button( Button(
icon=True, icon=True,
@ -306,9 +293,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
size="xs", size="xs",
children=[ children=[
Icon("play"), Icon("play"),
truncate( truncate(f"{last_session.game.name}"),
f"{last_session.purchase.first_edition.name}"
),
], ],
) )
], ],
@ -318,16 +303,13 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
else "", else "",
], ],
), ),
"columns": ["Edition", "Date", "Duration", "Actions"], "columns": ["Game", "Date", "Duration", "Actions"],
"rows": [ "rows": [
[ [
NameWithPlatformIcon( NameWithIcon(
name=session.purchase.name session_id=session.pk,
if session.purchase.name
else session.purchase.first_edition.name,
platform=session.purchase.platform,
), ),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
( (
format_duration(session.duration_calculated, durationformat) format_duration(session.duration_calculated, durationformat)
if session.duration_calculated if session.duration_calculated
@ -370,12 +352,37 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
], ],
} }
playevents = game.playevents.all()
playevent_count = playevents.count()
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
statuschange_data = {
"columns": [
"Old Status",
"New Status",
"Timestamp",
],
"rows": [
[
statuschange.get_old_status_display()
if statuschange.old_status
else "-",
statuschange.get_new_status_display(),
local_strftime(statuschange.timestamp, dateformat),
]
for statuschange in statuschanges
],
}
context: dict[str, Any] = { context: dict[str, Any] = {
"edition_count": editions.count(), "statuschange_data": statuschange_data,
"editions": editions, "statuschange_count": statuschange_count,
"statuschanges": statuschanges,
"game": game, "game": game,
"playrange": playrange, "playrange": playrange,
"purchase_count": Purchase.objects.filter(editions__game=game).count(), "purchase_count": game.purchases.count(),
"session_average_without_manual": round( "session_average_without_manual": round(
safe_division( safe_division(
total_hours_without_manual, int(session_count_without_manual) total_hours_without_manual, int(session_count_without_manual)
@ -386,8 +393,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"sessions": sessions, "sessions": sessions,
"title": f"Game Overview - {game.name}", "title": f"Game Overview - {game.name}",
"hours_sum": total_hours, "hours_sum": total_hours,
"edition_data": edition_data,
"purchase_data": purchase_data, "purchase_data": purchase_data,
"playevent_data": playevent_data,
"playevent_count": playevent_count,
"session_data": session_data, "session_data": session_data,
"session_page_obj": session_page_obj, "session_page_obj": session_page_obj,
"session_elided_page_range": ( "session_elided_page_range": (

View File

@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, Callable from typing import Any, Callable
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -8,19 +8,32 @@ from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now as timezone_now
from common.time import available_stats_year_range, dateformat, format_duration from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division from common.utils import safe_division
from games.models import Edition, Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
def model_counts(request: HttpRequest) -> dict[str, bool]: def model_counts(request: HttpRequest) -> dict[str, bool]:
now = timezone_now()
this_day, this_month, this_year = now.day, now.month, now.year
today_played = Session.objects.filter(
timestamp_start__day=this_day,
timestamp_start__month=this_month,
timestamp_start__year=this_year,
).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"]
last_7_played = Session.objects.filter(
timestamp_start__gte=(now - timedelta(days=7))
).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"]
return { return {
"game_available": Game.objects.exists(), "game_available": Game.objects.exists(),
"edition_available": Edition.objects.exists(),
"platform_available": Platform.objects.exists(), "platform_available": Platform.objects.exists(),
"purchase_available": Purchase.objects.exists(), "purchase_available": Purchase.objects.exists(),
"session_count": Session.objects.exists(), "session_count": Session.objects.exists(),
"today_played": format_duration(today_played, "%H h %m m"),
"last_7_played": format_duration(last_7_played, "%H h %m m"),
} }
@ -49,9 +62,7 @@ def use_custom_redirect(
@login_required @login_required
def stats_alltime(request: HttpRequest) -> HttpResponse: def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime" year = "Alltime"
this_year_sessions = Session.objects.all().prefetch_related( this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
Prefetch("purchase__editions")
)
this_year_sessions_with_durations = this_year_sessions.annotate( this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper( duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), F("timestamp_end") - F("timestamp_start"),
@ -59,11 +70,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
) )
) )
longest_session = this_year_sessions_with_durations.order_by("-duration").first() longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter( this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
editions__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate( this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("editions__purchase__session"), session_count=Count("sessions"),
) )
game_highest_session_count = this_year_games_with_session_counts.order_by( game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count" "-session_count"
@ -76,11 +85,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
.aggregate(dates=Count("date")) .aggregate(dates=Count("date"))
) )
this_year_played_purchases = Purchase.objects.filter( this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions games__sessions__in=this_year_sessions
).distinct() ).distinct()
this_year_purchases = Purchase.objects.all() this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("editions") this_year_purchases_with_currency = this_year_purchases.select_related("games")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None date_refunded=None
) )
@ -129,11 +138,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions) Game.objects.filter(sessions__in=this_year_sessions)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("editions__purchase__session__duration_calculated") F("sessions__duration_calculated") + F("sessions__duration_manual")
+ F("editions__purchase__session__duration_manual")
) )
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
@ -148,10 +156,8 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H") month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions) Game.objects.filter(sessions__in=this_year_sessions)
.annotate( .annotate(session_average=Avg("sessions__duration_calculated"))
session_average=Avg("editions__purchase__session__duration_calculated")
)
.order_by("-session_average") .order_by("-session_average")
.first() .first()
) )
@ -160,9 +166,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H") game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = ( total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name") this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name")) .annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime") .values("platform_name", "total_playtime")
.order_by("-total_playtime") .order_by("-total_playtime")
) )
@ -177,10 +183,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
last_play_date = "N/A" last_play_date = "N/A"
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.first_edition.game first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat) first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest() last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -195,7 +201,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
"total_hours": format_duration( "total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H" this_year_sessions.total_duration_unformatted(), "%2.0H"
), ),
"total_2023_games": this_year_played_purchases.all().count(), "total_year_games": this_year_played_purchases.all().count(),
"top_10_games_by_playtime": top_10_games_by_playtime, "top_10_games_by_playtime": top_10_games_by_playtime,
"year": year, "year": year,
"total_playtime_per_platform": total_playtime_per_platform, "total_playtime_per_platform": total_playtime_per_platform,
@ -228,9 +234,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
if longest_session if longest_session
else 0 else 0
), ),
"longest_session_game": ( "longest_session_game": (longest_session.game if longest_session else None),
longest_session.purchase.first_edition.game if longest_session else None
),
"highest_session_count": ( "highest_session_count": (
game_highest_session_count.session_count game_highest_session_count.session_count
if game_highest_session_count if game_highest_session_count
@ -268,7 +272,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("stats_alltime")) return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter( this_year_sessions = Session.objects.filter(
timestamp_start__year=year timestamp_start__year=year
).prefetch_related("purchase__editions") ).prefetch_related("game")
this_year_sessions_with_durations = this_year_sessions.annotate( this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper( duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), F("timestamp_end") - F("timestamp_start"),
@ -276,13 +280,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
) )
) )
longest_session = this_year_sessions_with_durations.order_by("-duration").first() longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter( this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
edition__purchases__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate( this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count( session_count=Count(
"edition__purchases__session", "sessions",
filter=Q(edition__purchases__session__timestamp_start__year=year), filter=Q(sessions__timestamp_start__year=year),
) )
) )
game_highest_session_count = this_year_games_with_session_counts.order_by( game_highest_session_count = this_year_games_with_session_counts.order_by(
@ -296,30 +298,46 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
.aggregate(dates=Count("date")) .aggregate(dates=Count("date"))
) )
this_year_played_purchases = Purchase.objects.filter( this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions games__sessions__in=this_year_sessions
).distinct() ).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year) this_year_played_games = Game.objects.filter(
this_year_purchases_with_currency = this_year_purchases.prefetch_related("editions") sessions__in=this_year_sessions
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( ).distinct()
date_refunded=None
).exclude(ownership_type=Purchase.DEMO)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases = Purchase.objects.filter(
date_purchased__year=year
).prefetch_related("games")
# purchased this year
# not refunded
this_year_purchases_without_refunded = Purchase.objects.filter(
date_refunded=None, date_purchased__year=year
)
# purchased this year
# not refunded
# not finished
# not infinite
# only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(date_finished__isnull=True) this_year_purchases_without_refunded.exclude(
games__in=Game.objects.filter(status="f")
)
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc. )
# not finished
this_year_purchases_unfinished = ( this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.exclude(
date_dropped__isnull=True games__status__in="ura"
) )
) )
# abandoned
# retired
this_year_purchases_dropped = ( this_year_purchases_dropped = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.exclude(
date_dropped__isnull=False games__in=Game.objects.filter(status="ar")
) )
) )
@ -335,15 +353,17 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
* 100 * 100
) )
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) purchases_finished_this_year = Purchase.objects.filter(
games__playevents__ended__year=2025
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
purchases_finished_this_year_released_this_year = ( purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(editions__year_released=year).order_by( purchases_finished_this_year.filter(games__year_released=year).order_by(
"date_finished" "games__playevents__ended"
) )
) )
purchased_this_year_finished_this_year = ( purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(date_finished__year=year) this_year_purchases_without_refunded.filter(games__playevents__ended__year=year)
).order_by("date_finished") ).order_by("games__playevents__ended")
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price")) total_spent=Sum(F("converted_price"))
@ -351,11 +371,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(edition__purchases__session__in=this_year_sessions) Game.objects.filter(sessions__in=this_year_sessions)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("edition__purchases__session__duration_calculated") F("sessions__duration_calculated") + F("sessions__duration_manual")
+ F("edition__purchases__session__duration_manual")
) )
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
@ -370,21 +389,19 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H") month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(edition__purchases__session__in=this_year_sessions) Game.objects.filter(sessions__in=this_year_sessions)
.annotate( .annotate(session_average=Avg("sessions__duration_calculated"))
session_average=Avg("edition__purchases__session__duration_calculated")
)
.order_by("-session_average") .order_by("-session_average")
.first() .first()
) )
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
for game in top_10_games_by_playtime: for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H") game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = ( total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name") this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name")) .annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime") .values("platform_name", "total_playtime")
.order_by("-total_playtime") .order_by("-total_playtime")
) )
@ -392,8 +409,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H") item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = ( backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year) Purchase.objects.filter(date_purchased__year__lt=2025)
.intersection(purchases_finished_this_year) .filter(games__status="f")
.filter(games__playevents__ended__year=2025)
.count() .count()
) )
@ -403,13 +421,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = None last_play_game = None
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.first_edition.game first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat) first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest() last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases.count()
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
date_purchased__year=year
)
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count() all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count() this_year_purchases_dropped_count = this_year_purchases_dropped.count()
@ -421,9 +442,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"total_hours": format_duration( "total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H" this_year_sessions.total_duration_unformatted(), "%2.0H"
), ),
"total_games": this_year_played_purchases.count(), "total_games": this_year_played_games.count(),
"total_2023_games": this_year_played_purchases.filter( "total_year_games": this_year_played_purchases.filter(
editions__year_released=year games__year_released=year
).count(), ).count(),
"top_10_games_by_playtime": top_10_games_by_playtime, "top_10_games_by_playtime": top_10_games_by_playtime,
"year": year, "year": year,
@ -435,16 +456,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
safe_division(total_spent, this_year_purchases_without_refunded_count) safe_division(total_spent, this_year_purchases_without_refunded_count)
), ),
"all_finished_this_year": purchases_finished_this_year.prefetch_related( "all_finished_this_year": purchases_finished_this_year.prefetch_related(
"editions" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"all_finished_this_year_count": purchases_finished_this_year.count(), "all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related( "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"editions" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"editions" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"total_sessions": this_year_sessions.count(), "total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"], "unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100), "unique_days_percent": int(unique_days["dates"] / 365 * 100),
@ -462,9 +483,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"all_purchased_refunded_this_year": this_year_purchases_refunded, "all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year": this_year_purchases_with_currency.order_by( "all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
"date_purchased"
),
"all_purchased_this_year_count": all_purchased_this_year_count, "all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count, "backlog_decrease_count": backlog_decrease_count,
"longest_session_time": ( "longest_session_time": (
@ -472,9 +491,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
if longest_session if longest_session
else 0 else 0
), ),
"longest_session_game": ( "longest_session_game": (longest_session.game if longest_session else None),
longest_session.purchase.first_edition.game if longest_session else None
),
"highest_session_count": ( "highest_session_count": (
game_highest_session_count.session_count game_highest_session_count.session_count
if game_highest_session_count if game_highest_session_count

147
games/views/playevent.py Normal file
View File

@ -0,0 +1,147 @@
import logging
from typing import Any, Callable, TypedDict
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import QuerySet
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.components import A, Button, Icon
from common.time import dateformat, local_strftime
from games.forms import PlayEventForm
from games.models import Game, PlayEvent
logger = logging.getLogger("games")
class TableData(TypedDict):
header_action: Callable[..., Any]
columns: list[str]
rows: list[list[Any]]
def create_playevent_tabledata(
playevents: list[PlayEvent] | BaseManager[PlayEvent] | QuerySet[PlayEvent],
exclude_columns: list[str] = [],
request: HttpRequest | None = None,
) -> TableData:
column_list = [
"Game",
"Started",
"Ended",
"Days to finish",
"Note",
"Created",
"Actions",
]
filtered_column_list = filter(
lambda x: x not in exclude_columns,
column_list,
)
excluded_column_indexes = [column_list.index(column) for column in exclude_columns]
row_list = [
[
playevent.game,
playevent.started.strftime(dateformat) if playevent.started else "-",
playevent.ended.strftime(dateformat) if playevent.ended else "-",
playevent.days_to_finish if playevent.days_to_finish else "-",
playevent.note,
local_strftime(playevent.created_at, dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("edit_playevent", args=[playevent.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("delete_playevent", args=[playevent.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for playevent in playevents
]
filtered_row_list = [
[column for idx, column in enumerate(row) if idx not in excluded_column_indexes]
for row in row_list
]
return {
"header_action": A([], Button([], "Add play event"), url="add_playevent"),
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
@login_required
def list_playevents(request: HttpRequest) -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
playevents = PlayEvent.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(playevents, limit)
page_obj = paginator.get_page(page_number)
playevents = page_obj.object_list
context: dict[str, Any] = {
"title": "Manage play events",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": create_playevent_tabledata(playevents, request=request),
}
return render(request, "list_playevents.html", context)
@login_required
def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
initial: dict[str, Any] = {}
if game_id:
# coming from add_playevent_for_game url path
game = get_object_or_404(Game, id=game_id)
initial["game"] = game
form = PlayEventForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
if not game_id:
# coming from add_playevent url path
game_id = form.instance.game.id
return HttpResponseRedirect(reverse("view_game", args=[game_id]))
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
context: dict[str, Any] = {}
playevent = get_object_or_404(PlayEvent, id=playevent_id)
form = PlayEventForm(request.POST or None, instance=playevent)
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse("view_game", args=[playevent.game.id]))
context = {
"form": form,
"title": "Edit Play Event",
}
return render(request, "add.html", context)
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

View File

@ -16,7 +16,7 @@ from django.utils import timezone
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
from common.time import dateformat from common.time import dateformat
from games.forms import PurchaseForm from games.forms import PurchaseForm
from games.models import Edition, Purchase from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@ -51,8 +51,6 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
"Infinite", "Infinite",
"Purchased", "Purchased",
"Refunded", "Refunded",
"Finished",
"Dropped",
"Created", "Created",
"Actions", "Actions",
], ],
@ -68,39 +66,11 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
if purchase.date_refunded if purchase.date_refunded
else "-" else "-"
), ),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat), purchase.created_at.strftime(dateformat),
render_to_string( render_to_string(
"cotton/button_group.html", "cotton/button_group.html",
{ {
"buttons": [ "buttons": [
{
"href": reverse(
"finish_purchase", args=[purchase.pk]
),
"slot": Icon("checkmark"),
"title": "Mark as finished",
}
if not purchase.date_finished
else {},
{
"href": reverse(
"drop_purchase", args=[purchase.pk]
),
"slot": Icon("eject"),
"title": "Mark as dropped",
}
if not purchase.date_dropped
else {},
{ {
"href": reverse( "href": reverse(
"refund_purchase", args=[purchase.pk] "refund_purchase", args=[purchase.pk]
@ -138,7 +108,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
@login_required @login_required
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse: def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {} context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": timezone.now()}
@ -149,19 +119,20 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
if "submit_and_redirect" in request.POST: if "submit_and_redirect" in request.POST:
return HttpResponseRedirect( return HttpResponseRedirect(
reverse( reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id} "add_session_for_game",
kwargs={"game_id": purchase.first_game.id},
) )
) )
else: else:
return redirect("list_purchases") return redirect("list_purchases")
else: else:
if edition_id: if game_id:
edition = Edition.objects.get(id=edition_id) game = Game.objects.get(id=game_id)
form = PurchaseForm( form = PurchaseForm(
initial={ initial={
**initial, **initial,
"edition": edition, "games": [game],
"platform": edition.platform, "platform": game.platform,
} }
) )
else: else:
@ -199,7 +170,11 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
@login_required @login_required
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
return render(request, "view_purchase.html", {"purchase": purchase}) return render(
request,
"view_purchase.html",
{"purchase": purchase, "title": f"Purchase: {purchase.full_name}"},
)
@login_required @login_required
@ -226,12 +201,14 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("list_purchases") return redirect("list_purchases")
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse: def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
edition_id = request.GET.get("edition") games = request.GET.getlist("games")
if not edition_id: if not games:
return HttpResponseBadRequest("Invalid edition_id") return HttpResponseBadRequest("Invalid game_id")
if isinstance(games, int) or isinstance(games, str):
games = [games]
form = PurchaseForm() form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter( form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME games__in=games, type=Purchase.GAME
).order_by("edition__sort_name") ).order_by("games__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form}) return render(request, "partials/related_purchase_field.html", {"form": form})

View File

@ -15,7 +15,7 @@ from common.components import (
Div, Div,
Form, Form,
Icon, Icon,
LinkedNameWithPlatformIcon, NameWithIcon,
Popover, Popover,
) )
from common.time import ( from common.time import (
@ -28,7 +28,7 @@ from common.time import (
) )
from common.utils import truncate from common.utils import truncate
from games.forms import SessionForm from games.forms import SessionForm
from games.models import Purchase, Session from games.models import Game, Session
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@ -37,13 +37,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
context: dict[Any, Any] = {} context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1) page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10) limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start") sessions = Session.objects.order_by("-timestamp_start", "created_at")
search_string = request.GET.get("search_string", search_string) search_string = request.GET.get("search_string", search_string)
if search_string != "": if search_string != "":
sessions = sessions.filter( sessions = sessions.filter(
Q(purchase__edition__name__icontains=search_string) Q(game__name__icontains=search_string)
| Q(purchase__edition__game__name__icontains=search_string) | Q(game__name__icontains=search_string)
| Q(purchase__platform__name__icontains=search_string) | Q(game__platform__name__icontains=search_string)
| Q(device__name__icontains=search_string) | Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string) | Q(device__type__icontains=search_string)
) )
@ -97,7 +97,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
args=[last_session.pk], args=[last_session.pk],
), ),
children=Popover( children=Popover(
popover_content=last_session.purchase.first_edition.name, popover_content=last_session.game.name,
children=[ children=[
Button( Button(
icon=True, icon=True,
@ -105,9 +105,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
size="xs", size="xs",
children=[ children=[
Icon("play"), Icon("play"),
truncate( truncate(f"{last_session.game.name}"),
f"{last_session.purchase.first_edition.name}"
),
], ],
) )
], ],
@ -130,12 +128,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
], ],
"rows": [ "rows": [
[ [
LinkedNameWithPlatformIcon( NameWithIcon(session_id=session.pk),
name=session.purchase.first_edition.name, f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
game_id=session.purchase.first_edition.game.pk,
platform=session.purchase.platform,
),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
( (
format_duration(session.duration_calculated, durationformat) format_duration(session.duration_calculated, durationformat)
if session.duration_calculated if session.duration_calculated
@ -195,13 +189,13 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
@login_required @login_required
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse: def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context = {} context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()} initial: dict[str, Any] = {"timestamp_start": timezone.now()}
last = Session.objects.last() last = Session.objects.last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["game"] = last.game
if request.method == "POST": if request.method == "POST":
form = SessionForm(request.POST or None, initial=initial) form = SessionForm(request.POST or None, initial=initial)
@ -209,21 +203,22 @@ def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
form.save() form.save()
return redirect("list_sessions") return redirect("list_sessions")
else: else:
if purchase_id: if game_id:
purchase = Purchase.objects.get(id=purchase_id) game = Game.objects.get(id=game_id)
form = SessionForm( form = SessionForm(
initial={ initial={
**initial, **initial,
"purchase": purchase, "game": game,
} }
) )
else: else:
form = SessionForm(initial=initial) form = SessionForm(initial=initial)
context["title"] = "Add New Session" context["title"] = "Add New Session"
context["script_name"] = "add_session.js" # TODO: re-add custom buttons #91
# context["script_name"] = "add_session.js"
context["form"] = form context["form"] = form
return render(request, "add_session.html", context) return render(request, "add.html", context)
@login_required @login_required
@ -237,7 +232,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
return redirect("list_sessions") return redirect("list_sessions")
context["title"] = "Edit Session" context["title"] = "Edit Session"
context["form"] = form context["form"] = form
return render(request, "add_session.html", context) return render(request, "add.html", context)
def clone_session_by_id(session_id: int) -> Session: def clone_session_by_id(session_id: int) -> Session:

337
poetry.lock generated
View File

@ -1,4 +1,16 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]] [[package]]
name = "asgiref" name = "asgiref"
@ -6,6 +18,7 @@ version = "3.8.1"
description = "ASGI specs, helper code, and adapters" description = "ASGI specs, helper code, and adapters"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"]
files = [ files = [
{file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"},
{file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"},
@ -20,6 +33,7 @@ version = "2024.8.30"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"]
files = [ files = [
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
@ -31,6 +45,7 @@ version = "3.4.0"
description = "Validate configuration and produce human readable error messages." description = "Validate configuration and produce human readable error messages."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
@ -42,6 +57,7 @@ version = "3.4.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false optional = false
python-versions = ">=3.7.0" python-versions = ">=3.7.0"
groups = ["main"]
files = [ files = [
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
@ -156,6 +172,7 @@ version = "8.1.7"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main", "dev"]
files = [ files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
@ -170,10 +187,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
markers = {main = "platform_system == \"Windows\""}
[[package]] [[package]]
name = "croniter" name = "croniter"
@ -181,6 +200,7 @@ version = "5.0.1"
description = "croniter provides iteration for datetime object with cron like format" description = "croniter provides iteration for datetime object with cron like format"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6"
groups = ["main"]
files = [ files = [
{file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"}, {file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"},
{file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"}, {file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"},
@ -196,6 +216,7 @@ version = "1.15.1"
description = "CSS unobfuscator and beautifier." description = "CSS unobfuscator and beautifier."
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["dev"]
files = [ files = [
{file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"}, {file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"},
] ]
@ -211,6 +232,7 @@ version = "0.3.9"
description = "Distribution utilities" description = "Distribution utilities"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["dev"]
files = [ files = [
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
@ -218,13 +240,14 @@ files = [
[[package]] [[package]]
name = "django" name = "django"
version = "5.1.3" version = "5.1.7"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main", "dev"]
files = [ files = [
{file = "Django-5.1.3-py3-none-any.whl", hash = "sha256:8b38a9a12da3ae00cb0ba72da985ec4b14de6345046b1e174b1fd7254398f818"}, {file = "Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b"},
{file = "Django-5.1.3.tar.gz", hash = "sha256:c0fa0e619c39325a169208caef234f90baa925227032ad3f44842ba14d75234a"}, {file = "Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c"},
] ]
[package.dependencies] [package.dependencies]
@ -238,13 +261,14 @@ bcrypt = ["bcrypt"]
[[package]] [[package]]
name = "django-cotton" name = "django-cotton"
version = "1.3.0" version = "1.6.0"
description = "Bringing component based design to Django templates." description = "Bringing component based design to Django templates."
optional = false optional = false
python-versions = "<4,>=3.8" python-versions = "<4,>=3.8"
groups = ["main"]
files = [ files = [
{file = "django_cotton-1.3.0-py3-none-any.whl", hash = "sha256:a23f29b759c43423e2f901352c0810388839cc412f6985614153c6ccfcfc2595"}, {file = "django_cotton-1.6.0-py3-none-any.whl", hash = "sha256:46452e5fc9ddfff43ac3b10925ba63151e2e9143ffa665a9519178122204b456"},
{file = "django_cotton-1.3.0.tar.gz", hash = "sha256:8f4a15dd55c8ee9182cf7234c228ea45d9fcdec1de125221bce8d05af035730a"}, {file = "django_cotton-1.6.0.tar.gz", hash = "sha256:1feb2ab486491f304e701fda82f37e608f0b9874473b3ec92922f3891d1a6cd7"},
] ]
[package.dependencies] [package.dependencies]
@ -256,6 +280,7 @@ version = "4.4.6"
description = "A configurable set of panels that display various debug information about the current request/response." description = "A configurable set of panels that display various debug information about the current request/response."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45"}, {file = "django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45"},
{file = "django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044"}, {file = "django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044"},
@ -271,6 +296,7 @@ version = "3.2.3"
description = "Extensions for Django" description = "Extensions for Django"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["dev"]
files = [ files = [
{file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"}, {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"},
{file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"}, {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"},
@ -285,6 +311,7 @@ version = "1.21.0"
description = "Extensions for using Django with htmx." description = "Extensions for using Django with htmx."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:64bc31463017a80552b767bc216ee5700248fa72e7ccd2963495e69afbdb6abe"}, {file = "django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:64bc31463017a80552b767bc216ee5700248fa72e7ccd2963495e69afbdb6abe"},
{file = "django_htmx-1.21.0.tar.gz", hash = "sha256:6ed3b42effd5980f22e68f36cd14ee4311bff3b6cb8435a89e27f45995691572"}, {file = "django_htmx-1.21.0.tar.gz", hash = "sha256:6ed3b42effd5980f22e68f36cd14ee4311bff3b6cb8435a89e27f45995691572"},
@ -294,12 +321,34 @@ files = [
asgiref = ">=3.6" asgiref = ">=3.6"
django = ">=4.2" django = ">=4.2"
[[package]]
name = "django-ninja"
version = "1.3.0"
description = "Django Ninja - Fast Django REST framework"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "django_ninja-1.3.0-py3-none-any.whl", hash = "sha256:f58096b6c767d1403dfd6c49743f82d780d7b9688d9302ecab316ac1fa6131bb"},
{file = "django_ninja-1.3.0.tar.gz", hash = "sha256:5b320e2dc0f41a6032bfa7e1ebc33559ae1e911a426f0c6be6674a50b20819be"},
]
[package.dependencies]
Django = ">=3.1"
pydantic = ">=2.0,<3.0.0"
[package.extras]
dev = ["pre-commit"]
doc = ["markdown-include", "mkdocs", "mkdocs-material", "mkdocstrings"]
test = ["django-stubs", "mypy (==1.7.1)", "psycopg2-binary", "pytest", "pytest-asyncio", "pytest-cov", "pytest-django", "ruff (==0.5.7)"]
[[package]] [[package]]
name = "django-picklefield" name = "django-picklefield"
version = "3.2" version = "3.2"
description = "Pickled object field for Django" description = "Pickled object field for Django"
optional = false optional = false
python-versions = ">=3" python-versions = ">=3"
groups = ["main"]
files = [ files = [
{file = "django-picklefield-3.2.tar.gz", hash = "sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d"}, {file = "django-picklefield-3.2.tar.gz", hash = "sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d"},
{file = "django_picklefield-3.2-py3-none-any.whl", hash = "sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c"}, {file = "django_picklefield-3.2-py3-none-any.whl", hash = "sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c"},
@ -317,6 +366,7 @@ version = "1.7.4"
description = "A multiprocessing distributed task queue for Django" description = "A multiprocessing distributed task queue for Django"
optional = false optional = false
python-versions = "<4,>=3.8" python-versions = "<4,>=3.8"
groups = ["main"]
files = [ files = [
{file = "django_q2-1.7.4-py3-none-any.whl", hash = "sha256:6eda6d56505822ee5ebc6c4eac1dde726f5dbf20ee9ea7080575535852e2671f"}, {file = "django_q2-1.7.4-py3-none-any.whl", hash = "sha256:6eda6d56505822ee5ebc6c4eac1dde726f5dbf20ee9ea7080575535852e2671f"},
{file = "django_q2-1.7.4.tar.gz", hash = "sha256:56a3781cc480474fa9c04bbde62445b0a9b4195adc409bd963b8f593b0598c43"}, {file = "django_q2-1.7.4.tar.gz", hash = "sha256:56a3781cc480474fa9c04bbde62445b0a9b4195adc409bd963b8f593b0598c43"},
@ -337,6 +387,7 @@ version = "24.4"
description = "django-template-partials" description = "django-template-partials"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "django_template_partials-24.4-py2.py3-none-any.whl", hash = "sha256:ee59d3839385d7f648907c3fa8d5923fcd66cd8090f141fe2a1c338b917984e2"}, {file = "django_template_partials-24.4-py2.py3-none-any.whl", hash = "sha256:ee59d3839385d7f648907c3fa8d5923fcd66cd8090f141fe2a1c338b917984e2"},
{file = "django_template_partials-24.4.tar.gz", hash = "sha256:25b67301470fc274ecc419e5e5fd4686a5020b1c038fd241a70eb087809034b6"}, {file = "django_template_partials-24.4.tar.gz", hash = "sha256:25b67301470fc274ecc419e5e5fd4686a5020b1c038fd241a70eb087809034b6"},
@ -355,6 +406,7 @@ version = "3.0.7"
description = "Django/Jinja template indenter" description = "Django/Jinja template indenter"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["dev"]
files = [ files = [
{file = "djhtml-3.0.7.tar.gz", hash = "sha256:558c905b092a0c8afcbed27dea2f50aa6eb853a658b309e4e0f2bb378bdf6178"}, {file = "djhtml-3.0.7.tar.gz", hash = "sha256:558c905b092a0c8afcbed27dea2f50aa6eb853a658b309e4e0f2bb378bdf6178"},
] ]
@ -368,6 +420,7 @@ version = "1.36.1"
description = "HTML Template Linter and Formatter" description = "HTML Template Linter and Formatter"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "djlint-1.36.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef40527fd6cd82cdd18f65a6bf5b486b767d2386f6c21f2ebd60e5d88f487fe8"}, {file = "djlint-1.36.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef40527fd6cd82cdd18f65a6bf5b486b767d2386f6c21f2ebd60e5d88f487fe8"},
{file = "djlint-1.36.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4712de3dea172000a098da6a0cd709d158909b4964ba0f68bee584cef18b4878"}, {file = "djlint-1.36.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4712de3dea172000a098da6a0cd709d158909b4964ba0f68bee584cef18b4878"},
@ -410,6 +463,7 @@ version = "0.12.4"
description = "EditorConfig File Locator and Interpreter for Python" description = "EditorConfig File Locator and Interpreter for Python"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["dev"]
files = [ files = [
{file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"},
] ]
@ -420,6 +474,7 @@ version = "3.16.1"
description = "A platform independent file lock." description = "A platform independent file lock."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
{file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
@ -428,7 +483,7 @@ files = [
[package.extras] [package.extras]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
typing = ["typing-extensions (>=4.12.2)"] typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""]
[[package]] [[package]]
name = "graphene" name = "graphene"
@ -436,6 +491,7 @@ version = "3.4.3"
description = "GraphQL Framework for Python" description = "GraphQL Framework for Python"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"}, {file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"},
{file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"}, {file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"},
@ -457,6 +513,7 @@ version = "3.2.2"
description = "Graphene Django integration" description = "Graphene Django integration"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "graphene-django-3.2.2.tar.gz", hash = "sha256:059ccf25d9a5159f28d7ebf1a648c993ab34deb064e80b70ca096aa22a609556"}, {file = "graphene-django-3.2.2.tar.gz", hash = "sha256:059ccf25d9a5159f28d7ebf1a648c993ab34deb064e80b70ca096aa22a609556"},
{file = "graphene_django-3.2.2-py2.py3-none-any.whl", hash = "sha256:0fd95c8c1cbe77ae2a5940045ce276803c3acbf200a156731e0c730f2776ae2c"}, {file = "graphene_django-3.2.2-py2.py3-none-any.whl", hash = "sha256:0fd95c8c1cbe77ae2a5940045ce276803c3acbf200a156731e0c730f2776ae2c"},
@ -481,6 +538,7 @@ version = "3.2.5"
description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
optional = false optional = false
python-versions = "<4,>=3.6" python-versions = "<4,>=3.6"
groups = ["main"]
files = [ files = [
{file = "graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a"}, {file = "graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a"},
{file = "graphql_core-3.2.5.tar.gz", hash = "sha256:e671b90ed653c808715645e3998b7ab67d382d55467b7e2978549111bbabf8d5"}, {file = "graphql_core-3.2.5.tar.gz", hash = "sha256:e671b90ed653c808715645e3998b7ab67d382d55467b7e2978549111bbabf8d5"},
@ -492,6 +550,7 @@ version = "3.2.0"
description = "Relay library for graphql-core" description = "Relay library for graphql-core"
optional = false optional = false
python-versions = ">=3.6,<4" python-versions = ">=3.6,<4"
groups = ["main"]
files = [ files = [
{file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"}, {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"},
{file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"}, {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"},
@ -502,13 +561,14 @@ graphql-core = ">=3.2,<3.3"
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "22.0.0" version = "23.0.0"
description = "WSGI HTTP Server for UNIX" description = "WSGI HTTP Server for UNIX"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"},
{file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"},
] ]
[package.dependencies] [package.dependencies]
@ -527,6 +587,7 @@ version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
@ -538,6 +599,7 @@ version = "2.6.2"
description = "File identification library for Python" description = "File identification library for Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"}, {file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"},
{file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"}, {file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"},
@ -552,6 +614,7 @@ version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"]
files = [ files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@ -566,6 +629,7 @@ version = "2.0.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
@ -577,6 +641,7 @@ version = "5.13.2"
description = "A Python utility / library to sort Python imports." description = "A Python utility / library to sort Python imports."
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["dev"]
files = [ files = [
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
@ -591,6 +656,7 @@ version = "1.15.1"
description = "JavaScript unobfuscator and beautifier." description = "JavaScript unobfuscator and beautifier."
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["dev"]
files = [ files = [
{file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"},
] ]
@ -605,6 +671,7 @@ version = "0.9.25"
description = "A Python implementation of the JSON5 data format." description = "A Python implementation of the JSON5 data format."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"}, {file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"},
{file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"}, {file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"},
@ -616,6 +683,7 @@ version = "3.7"
description = "Python implementation of John Gruber's Markdown." description = "Python implementation of John Gruber's Markdown."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"},
{file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"},
@ -627,48 +695,49 @@ testing = ["coverage", "pyyaml"]
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "1.13.0" version = "1.15.0"
description = "Optional static typing for Python" description = "Optional static typing for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"},
{file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"},
{file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"},
{file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"},
{file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"},
{file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"},
{file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"},
{file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"},
{file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"},
{file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"},
{file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"},
{file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"},
{file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"},
{file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"},
{file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"},
{file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"},
{file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"},
{file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"},
{file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"},
{file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"},
{file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"},
{file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"},
{file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"},
{file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"},
{file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"},
{file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"},
{file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"},
{file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"},
{file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"},
{file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"},
{file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"},
{file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"},
] ]
[package.dependencies] [package.dependencies]
mypy-extensions = ">=1.0.0" mypy_extensions = ">=1.0.0"
typing-extensions = ">=4.6.0" typing_extensions = ">=4.6.0"
[package.extras] [package.extras]
dmypy = ["psutil (>=4.0)"] dmypy = ["psutil (>=4.0)"]
@ -683,6 +752,7 @@ version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker." description = "Type system extensions for programs checked with the mypy type checker."
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
groups = ["dev"]
files = [ files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
@ -694,6 +764,7 @@ version = "1.9.1"
description = "Node.js virtual environment builder" description = "Node.js virtual environment builder"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
files = [ files = [
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
@ -705,6 +776,7 @@ version = "24.2"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"]
files = [ files = [
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
@ -716,6 +788,7 @@ version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
@ -727,6 +800,7 @@ version = "4.3.6"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
@ -743,6 +817,7 @@ version = "1.5.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
@ -758,6 +833,7 @@ version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
@ -776,6 +852,7 @@ version = "2.3"
description = "Promises/A+ implementation for Python" description = "Promises/A+ implementation for Python"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"}, {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"},
] ]
@ -786,12 +863,147 @@ six = "*"
[package.extras] [package.extras]
test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark", "pytest-cov"] test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark", "pytest-cov"]
[[package]]
name = "pydantic"
version = "2.10.6"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"},
{file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
pydantic-core = "2.27.2"
typing-extensions = ">=4.12.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]]
name = "pydantic-core"
version = "2.27.2"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"},
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"},
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"},
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"},
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"},
{file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"},
{file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"},
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"},
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"},
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"},
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"},
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"},
{file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"},
{file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"},
{file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"},
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"},
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"},
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"},
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"},
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"},
{file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"},
{file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"},
{file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"},
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"},
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"},
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"},
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"},
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"},
{file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"},
{file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"},
{file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"},
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"},
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"},
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"},
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"},
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"},
{file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"},
{file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"},
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"},
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"},
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"},
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"},
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"},
{file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"},
{file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"},
{file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.3" version = "8.3.3"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
@ -812,6 +1024,7 @@ version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module" description = "Extensions to the standard Python datetime module"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [ files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
@ -826,6 +1039,7 @@ version = "2024.2"
description = "World timezone definitions, modern and historical" description = "World timezone definitions, modern and historical"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
{file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
@ -837,6 +1051,7 @@ version = "6.0.2"
description = "YAML parser and emitter for Python" description = "YAML parser and emitter for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"]
files = [ files = [
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
@ -899,6 +1114,7 @@ version = "2024.11.6"
description = "Alternative regular expression module, to replace re." description = "Alternative regular expression module, to replace re."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"},
{file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"},
@ -1002,6 +1218,7 @@ version = "2.32.3"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
@ -1023,6 +1240,7 @@ version = "1.16.0"
description = "Python 2 and 3 compatibility utilities" description = "Python 2 and 3 compatibility utilities"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
groups = ["main", "dev"]
files = [ files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
@ -1034,6 +1252,7 @@ version = "0.5.1"
description = "A non-validating SQL parser." description = "A non-validating SQL parser."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"]
files = [ files = [
{file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"},
{file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"},
@ -1049,6 +1268,7 @@ version = "1.3"
description = "The most basic Text::Unidecode port" description = "The most basic Text::Unidecode port"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
@ -1060,6 +1280,7 @@ version = "4.67.0"
description = "Fast, Extensible Progress Meter" description = "Fast, Extensible Progress Meter"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be"}, {file = "tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be"},
{file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"}, {file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"},
@ -1081,6 +1302,7 @@ version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+" description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"]
files = [ files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
@ -1092,6 +1314,8 @@ version = "2024.2"
description = "Provider of IANA time zone data" description = "Provider of IANA time zone data"
optional = false optional = false
python-versions = ">=2" python-versions = ">=2"
groups = ["main", "dev"]
markers = "sys_platform == \"win32\""
files = [ files = [
{file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
{file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
@ -1103,13 +1327,14 @@ version = "2.2.3"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
{file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
] ]
[package.extras] [package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"] h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"] zstd = ["zstandard (>=0.18.0)"]
@ -1120,6 +1345,7 @@ version = "0.30.6"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"},
{file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"},
@ -1130,17 +1356,18 @@ click = ">=7.0"
h11 = ">=0.8" h11 = ">=0.8"
[package.extras] [package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.27.1" version = "20.29.1"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"}, {file = "virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779"},
{file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"}, {file = "virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35"},
] ]
[package.dependencies] [package.dependencies]
@ -1150,9 +1377,9 @@ platformdirs = ">=3.9.1,<5"
[package.extras] [package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.1"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "b5bb46a6591964aec145637cd9a412a681f2cc5e7e4fdd6fd9ecb0fe8724b8e3" content-hash = "6538de3a90b0a21de23da3768e8eeb6f520b09a61eb33454bb8a2b50444c14f5"

View File

@ -22,7 +22,7 @@ django-debug-toolbar = "^4.4.2"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.11" python = "^3.11"
django = "^5.0.6" django = "^5.0.6"
gunicorn = "^22.0.0" gunicorn = "^23.0.0"
uvicorn = "^0.30.1" uvicorn = "^0.30.1"
graphene-django = "^3.2.0" graphene-django = "^3.2.0"
django-htmx = "^1.18.0" django-htmx = "^1.18.0"
@ -34,6 +34,7 @@ django-q2 = "^1.7.4"
croniter = "^5.0.1" croniter = "^5.0.1"
requests = "^2.32.3" requests = "^2.32.3"
pyyaml = "^6.0.2" pyyaml = "^6.0.2"
django-ninja = "^1.3.0"
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View File

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

View File

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

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