Compare commits
36 Commits
filters_fi
...
main
Author | SHA1 | Date |
---|---|---|
|
843eed64d6 | |
|
50e7efcfae | |
|
3e713a7637 | |
|
2d7342c0d5 | |
|
aba9bc994d | |
|
967ff7df07 | |
|
2ab497fd54 | |
|
34148466c7 | |
|
b22e185d47 | |
|
b2b69339b3 | |
|
89d1bbdd9e | |
|
637e3e6493 | |
|
d213a3d35d | |
|
2f4e16dd54 | |
|
6f62889e92 | |
|
4ec808eeec | |
|
69d27958f3 | |
|
4ec1cf5f28 | |
|
d936fdc60d | |
|
2116cfc219 | |
|
6bd8271291 | |
|
e571feadef | |
|
23c1ce1f96 | |
|
33103daebc | |
|
ba6028e43d | |
|
c2853a3ecc | |
|
cd90d60475 | |
|
11cea2142a | |
|
24578b64fe | |
|
13e607f9a7 | |
|
fc0d8db8e8 | |
|
8acc4f9c5b | |
|
6b7a96dc06 | |
|
5c5fd5f26a | |
|
7181b6472c | |
|
af06d07ee3 |
|
@ -1,20 +0,0 @@
|
|||
repos:
|
||||
# disable due to incomaptible formatting between
|
||||
# black and ruff
|
||||
# TODO: replace with ruff when it works on NixOS
|
||||
# - repo: https://github.com/psf/black
|
||||
# rev: 24.8.0
|
||||
# hooks:
|
||||
# - id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.34.0
|
||||
hooks:
|
||||
- id: djlint-reformat-django
|
||||
args: ["--ignore", "H011"]
|
||||
- id: djlint-django
|
||||
args: ["--ignore", "H011"]
|
|
@ -8,6 +8,8 @@
|
|||
* Add all-time stats
|
||||
* Manage purchases
|
||||
* Automatically convert purchase prices
|
||||
* Add emulated property to sessions
|
||||
* Add today's and last 7 days playtime stats to navbar
|
||||
|
||||
## Improved
|
||||
* mark refunded purchases red on game overview
|
||||
|
|
|
@ -3,11 +3,13 @@ from string import ascii_lowercase
|
|||
from typing import Any, Callable
|
||||
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.utils import truncate
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
HTMLTag = str
|
||||
|
@ -30,7 +32,7 @@ def Component(
|
|||
attributesList = [f'{name}="{value}"' for name, value in attributes]
|
||||
# make attribute list into a string
|
||||
# and insert space between tag and attribute list
|
||||
attributesBlob = f" {" ".join(attributesList)}"
|
||||
attributesBlob = f" {' '.join(attributesList)}"
|
||||
tag: str = ""
|
||||
if tag_name != "":
|
||||
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
|
||||
|
@ -50,6 +52,7 @@ def randomid(seed: str = "", length: int = 10) -> str:
|
|||
def Popover(
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] = [],
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
) -> str:
|
||||
|
@ -62,17 +65,43 @@ def Popover(
|
|||
("id", id),
|
||||
("wrapped_content", wrapped_content),
|
||||
("popover_content", popover_content),
|
||||
("wrapped_classes", wrapped_classes),
|
||||
],
|
||||
children=children,
|
||||
template="cotton/popover.html",
|
||||
)
|
||||
|
||||
|
||||
def PopoverTruncated(input_string: str) -> str:
|
||||
if (truncated := truncate(input_string)) != input_string:
|
||||
return Popover(wrapped_content=truncated, popover_content=input_string)
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
The truncated text ends in `ellipsis`, and optionally
|
||||
an always-visible `endpart` can be specified.
|
||||
`popover_content` can be specified if:
|
||||
1. It needs to be always displayed regardless if text is truncated.
|
||||
2. It needs to differ from `input_string`.
|
||||
"""
|
||||
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
|
||||
return Popover(
|
||||
wrapped_content=truncated,
|
||||
popover_content=popover_content if popover_content else input_string,
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
if popover_content and popover_if_not_truncated:
|
||||
return Popover(
|
||||
wrapped_content=input_string,
|
||||
popover_content=popover_content if popover_content else "",
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
|
@ -125,33 +154,14 @@ def Div(
|
|||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Label(
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
children: list[HTMLTag] | HTMLTag = [],
|
||||
):
|
||||
return Component(tag_name="label", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
label: str = "",
|
||||
id: str = "",
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
children: list[HTMLTag] | HTMLTag = [],
|
||||
):
|
||||
input_component = Component(
|
||||
tag_name="input",
|
||||
attributes=attributes + [("type", type), ("id", id)],
|
||||
children=children,
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
if label != "":
|
||||
if id == "":
|
||||
raise ValueError("Label is set but element ID is missing.")
|
||||
return Label(
|
||||
attributes=[("for", id)], children=[label, input_component, *children]
|
||||
)
|
||||
else:
|
||||
return input_component
|
||||
|
||||
|
||||
def Form(
|
||||
|
@ -167,74 +177,6 @@ def Form(
|
|||
)
|
||||
|
||||
|
||||
def Fieldset(
|
||||
label: str = "",
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
children: list[HTMLTag] | HTMLTag = [],
|
||||
):
|
||||
if label != "":
|
||||
children = [Label(children=[label, *children])]
|
||||
return Component(tag_name="fieldset", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def RadioFieldset(name: str, label: str, radio_buttons: list[dict[str, str]]):
|
||||
return Component(
|
||||
tag_name="span",
|
||||
children=[
|
||||
Component(tag_name="legend", children=label),
|
||||
Component(
|
||||
tag_name="fieldset",
|
||||
children=[
|
||||
Component(
|
||||
tag_name="label",
|
||||
attributes=[
|
||||
("for", f"{name}__{radio["value"]}"),
|
||||
],
|
||||
children=[
|
||||
radio["label"],
|
||||
Input(
|
||||
type="radio",
|
||||
attributes=[
|
||||
("id", f"{name}__{radio["value"]}"),
|
||||
("name", name),
|
||||
("value", radio["value"]),
|
||||
("onClick", radio.get("onclick", "")),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
for radio in radio_buttons
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def BooleanRadioFieldset(name: str, label: str):
|
||||
return RadioFieldset(
|
||||
name=name,
|
||||
label=label,
|
||||
radio_buttons=[
|
||||
{"label": "True", "value": "true"},
|
||||
{"label": "False", "value": "false"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def SubmitButton(label: str):
|
||||
return Input(type="submit", attributes=[("value", label)])
|
||||
|
||||
|
||||
# RadioFieldset(
|
||||
# name="filter__dropped",
|
||||
# label="Dropped",
|
||||
# radio_buttons=[
|
||||
# {"label": "True", "value": "true"},
|
||||
# {"label": "False", "value": "false"},
|
||||
# ],
|
||||
# )
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
|
@ -246,15 +188,83 @@ def Icon(
|
|||
return result
|
||||
|
||||
|
||||
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
|
||||
link = reverse("view_game", args=[int(game_id)])
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
link = reverse("view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = purchase.platform.icon if game_count == 1 else "unspecified"
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return mark_safe(A(url=link, children=[a_content]))
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
platform: str = "",
|
||||
game_id: int = 0,
|
||||
session_id: int = 0,
|
||||
purchase_id: int = 0,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
|
||||
create_link = True
|
||||
if session_id:
|
||||
session = Session.objects.get(pk=session_id)
|
||||
emulated = session.emulated
|
||||
game_id = session.game.pk
|
||||
if purchase_id:
|
||||
purchase = Purchase.objects.get(pk=purchase_id)
|
||||
game_id = purchase.games.first().pk
|
||||
if game_id:
|
||||
game = Game.objects.get(pk=game_id)
|
||||
name = name or game.name
|
||||
platform = game.platform
|
||||
link = reverse("view_game", args=[int(game_id)])
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
),
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if emulated else "",
|
||||
PopoverTruncated(name),
|
||||
],
|
||||
)
|
||||
|
@ -262,21 +272,16 @@ def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeTe
|
|||
return mark_safe(
|
||||
A(
|
||||
url=link,
|
||||
children=[a_content],
|
||||
),
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content,
|
||||
)
|
||||
|
||||
|
||||
def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
),
|
||||
PopoverTruncated(name),
|
||||
],
|
||||
def PurchasePrice(purchase) -> str:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
return mark_safe(content)
|
||||
|
|
|
@ -44,9 +44,9 @@
|
|||
transition: all 0.2s ease-out;
|
||||
} */
|
||||
|
||||
form label {
|
||||
/* form label {
|
||||
@apply dark:text-slate-400;
|
||||
}
|
||||
} */
|
||||
|
||||
.responsive-table {
|
||||
@apply dark:text-white mx-auto table-fixed;
|
||||
|
@ -90,37 +90,37 @@ form label {
|
|||
}
|
||||
}
|
||||
|
||||
form input,
|
||||
/* form input,
|
||||
select,
|
||||
textarea {
|
||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||
}
|
||||
} */
|
||||
|
||||
form input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
@apply dark:bg-slate-700 dark:text-slate-400;
|
||||
@apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
/* @media screen and (min-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
} */
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
/* @media screen and (max-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
} */
|
||||
|
||||
#button-container button {
|
||||
@apply mx-1;
|
||||
|
@ -169,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;
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import operator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Generator, TypeVar
|
||||
|
||||
from django.apps import apps
|
||||
from django.db.models import Model
|
||||
from functools import reduce
|
||||
from typing import Any, Callable, Generator, Literal, TypeVar
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
||||
"""
|
||||
|
@ -37,14 +38,31 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
|
|||
return obj
|
||||
|
||||
|
||||
def truncate(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
|
||||
def truncate_(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
|
||||
return (
|
||||
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
|
||||
if len(input_string) > 30
|
||||
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}")
|
||||
if len(input_string) > length
|
||||
else input_string
|
||||
)
|
||||
|
||||
|
||||
def truncate(
|
||||
input_string: str, length: int = 30, ellipsis: str = "…", endpart: str = ""
|
||||
) -> str:
|
||||
max_content_length = length - len(endpart)
|
||||
if max_content_length < 0:
|
||||
raise ValueError("Length cannot be shorter than the length of endpart.")
|
||||
|
||||
if len(input_string) > max_content_length:
|
||||
return f"{input_string[:max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
|
||||
|
||||
return (
|
||||
f"{input_string}{endpart}"
|
||||
if len(input_string) + len(endpart) <= length
|
||||
else f"{input_string[:length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T", str, int, date)
|
||||
|
||||
|
||||
|
@ -69,15 +87,44 @@ def format_float_or_int(number: int | float):
|
|||
return int(number) if float(number).is_integer() else f"{number:03.2f}"
|
||||
|
||||
|
||||
def get_model_by_string(app_label: str, model_name: str):
|
||||
return apps.get_model(app_label, model_name)
|
||||
OperatorType = Literal["|", "&"]
|
||||
|
||||
|
||||
def get_field(model: Model, field_name: str):
|
||||
field = model._meta.get_field(field_name)
|
||||
return field
|
||||
@dataclass
|
||||
class FilterEntry:
|
||||
condition: Q
|
||||
operator: OperatorType = "&"
|
||||
|
||||
|
||||
def get_field_type(model: Model, field_name: str):
|
||||
field = model._meta.get_field(field_name)
|
||||
return type(field)
|
||||
def build_dynamic_filter(
|
||||
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
|
||||
):
|
||||
"""
|
||||
Constructs a Django Q filter from a list of filter conditions.
|
||||
|
||||
Args:
|
||||
filters (list): A list where each item is either:
|
||||
- A Q object (default AND logic applied)
|
||||
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
|
||||
|
||||
Returns:
|
||||
Q: A combined Q object that can be passed to Django's filter().
|
||||
"""
|
||||
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
|
||||
"|": operator.or_,
|
||||
"&": operator.and_,
|
||||
}
|
||||
|
||||
# Convert all plain Q objects into (Q, "&") for default AND behavior
|
||||
processed_filters = [
|
||||
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
|
||||
]
|
||||
|
||||
# Reduce with dynamic operators
|
||||
return reduce(
|
||||
lambda combined_filters, filter: op_map[filter.operator](
|
||||
combined_filters, filter.condition
|
||||
),
|
||||
processed_filters,
|
||||
Q(),
|
||||
)
|
||||
|
|
|
@ -2,7 +2,6 @@ from django.contrib import admin
|
|||
|
||||
from games.models import (
|
||||
Device,
|
||||
Edition,
|
||||
ExchangeRate,
|
||||
Game,
|
||||
Platform,
|
||||
|
@ -15,6 +14,5 @@ admin.site.register(Game)
|
|||
admin.site.register(Purchase)
|
||||
admin.site.register(Platform)
|
||||
admin.site.register(Session)
|
||||
admin.site.register(Edition)
|
||||
admin.site.register(Device)
|
||||
admin.site.register(ExchangeRate)
|
||||
|
|
|
@ -11,6 +11,8 @@ class GamesConfig(AppConfig):
|
|||
name = "games"
|
||||
|
||||
def ready(self):
|
||||
import games.signals # noqa: F401
|
||||
|
||||
post_migrate.connect(schedule_tasks, sender=self)
|
||||
|
||||
|
||||
|
@ -24,6 +26,16 @@ def schedule_tasks(sender, **kwargs):
|
|||
name="Update converted prices",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
catchup=False,
|
||||
)
|
||||
|
||||
if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
schedule(
|
||||
"games.tasks.calculate_price_per_game",
|
||||
name="Update price per game",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
catchup=False,
|
||||
)
|
||||
|
||||
from games.models import ExchangeRate
|
||||
|
|
101
games/filters.py
101
games/filters.py
|
@ -1,101 +0,0 @@
|
|||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
FloatField,
|
||||
IntegerField,
|
||||
QuerySet,
|
||||
TextField,
|
||||
)
|
||||
from django.http import HttpRequest
|
||||
|
||||
from common.components import *
|
||||
from common.utils import get_field, get_model_by_string
|
||||
|
||||
filter_param_prefix = "f_"
|
||||
|
||||
|
||||
class Modifier(Enum):
|
||||
EQUALS = "__exact"
|
||||
GT = "__gt"
|
||||
LT = "__lt"
|
||||
CONTAINS = "__contains"
|
||||
REGEX = "__regex"
|
||||
ISNULL = "__isnull"
|
||||
BETWEEN = "__gt", "__lt"
|
||||
|
||||
|
||||
def create_filter_form(model: str, fields: list[str]):
|
||||
filter_model = get_model_by_string("games", model)
|
||||
automatic_filter_form_parts = []
|
||||
for field in fields:
|
||||
html_field_name = f"{filter_param_prefix}{field}"
|
||||
match get_field(filter_model, field):
|
||||
case BooleanField():
|
||||
automatic_filter_form_parts.append(
|
||||
BooleanRadioFieldset(name=html_field_name, label=field)
|
||||
)
|
||||
case TextField():
|
||||
pass
|
||||
case CharField():
|
||||
js = str
|
||||
onclick_handler: js = """f_price_currency.disabled = true;"""
|
||||
automatic_filter_form_parts.extend(
|
||||
[
|
||||
RadioFieldset(
|
||||
name=f"{field}_switch",
|
||||
label="Modifier",
|
||||
radio_buttons=[
|
||||
{
|
||||
"label": "Equals",
|
||||
"value": Modifier.EQUALS.value,
|
||||
"onclick": onclick_handler,
|
||||
},
|
||||
{"label": "Contains", "value": Modifier.CONTAINS.value},
|
||||
],
|
||||
),
|
||||
Input(
|
||||
label=field,
|
||||
id=html_field_name,
|
||||
attributes=[
|
||||
("name", html_field_name + str(Modifier.EQUALS.value))
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
case IntegerField():
|
||||
pass
|
||||
case FloatField():
|
||||
html = Input(
|
||||
label=field,
|
||||
type="number",
|
||||
id=html_field_name,
|
||||
attributes=[("name", html_field_name)],
|
||||
)
|
||||
automatic_filter_form_parts.append(html)
|
||||
case _:
|
||||
print(f"Field type of {field} not handled yet.")
|
||||
automatic_filter_form = Form(
|
||||
children=[*automatic_filter_form_parts, SubmitButton("Apply")]
|
||||
)
|
||||
return automatic_filter_form
|
||||
|
||||
|
||||
def apply_filters(request: HttpRequest, queryset: QuerySet[Any]):
|
||||
for parameter in request.GET:
|
||||
if parameter.startswith(filter_param_prefix):
|
||||
field_name = parameter.removeprefix(filter_param_prefix)
|
||||
field_value = request.GET.get(parameter)
|
||||
if field_value == "":
|
||||
continue
|
||||
match field_value:
|
||||
case "true":
|
||||
field_value = True
|
||||
case "false":
|
||||
field_value = False
|
||||
case _:
|
||||
pass
|
||||
queryset = queryset.filter(**{f"{field_name}": field_value})
|
||||
return queryset
|
|
@ -2,7 +2,7 @@ from django import forms
|
|||
from django.urls import reverse
|
||||
|
||||
from common.utils import safe_getattr
|
||||
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||
from games.models import Device, Game, Platform, Purchase, Session
|
||||
|
||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||
custom_datetime_widget = forms.DateTimeInput(
|
||||
|
@ -11,17 +11,30 @@ custom_datetime_widget = forms.DateTimeInput(
|
|||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
# purchase = forms.ModelChoiceField(
|
||||
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
|
||||
# )
|
||||
purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.order_by("edition__sort_name"),
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||
|
||||
mark_as_played = forms.BooleanField(
|
||||
required=False,
|
||||
initial={"mark_as_played": True},
|
||||
label="Set game status to Played if Unplayed",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
widgets = {
|
||||
"timestamp_start": custom_datetime_widget,
|
||||
|
@ -29,21 +42,30 @@ class SessionForm(forms.ModelForm):
|
|||
}
|
||||
model = Session
|
||||
fields = [
|
||||
"purchase",
|
||||
"game",
|
||||
"timestamp_start",
|
||||
"timestamp_end",
|
||||
"duration_manual",
|
||||
"emulated",
|
||||
"device",
|
||||
"note",
|
||||
"mark_as_played",
|
||||
]
|
||||
|
||||
|
||||
class EditionChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
def save(self, commit=True):
|
||||
session = super().save(commit=False)
|
||||
if self.cleaned_data.get("mark_as_played"):
|
||||
game_instance = session.game
|
||||
if game_instance.status == "u":
|
||||
game_instance.status = "p"
|
||||
if commit:
|
||||
game_instance.save()
|
||||
if commit:
|
||||
session.save()
|
||||
return session
|
||||
|
||||
|
||||
class IncludePlatformSelect(forms.Select):
|
||||
class IncludePlatformSelect(forms.SelectMultiple):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||
|
@ -56,26 +78,24 @@ class PurchaseForm(forms.ModelForm):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Automatically update related_purchase <select/>
|
||||
# to only include purchases of the selected edition.
|
||||
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
|
||||
self.fields["edition"].widget.attrs.update(
|
||||
# to only include purchases of the selected game.
|
||||
related_purchase_by_game_url = reverse("related_purchase_by_game")
|
||||
self.fields["games"].widget.attrs.update(
|
||||
{
|
||||
"hx-trigger": "load, click",
|
||||
"hx-get": related_purchase_by_edition_url,
|
||||
"hx-get": related_purchase_by_game_url,
|
||||
"hx-target": "#id_related_purchase",
|
||||
"hx-swap": "outerHTML",
|
||||
}
|
||||
)
|
||||
|
||||
edition = EditionChoiceField(
|
||||
queryset=Edition.objects.order_by("sort_name"),
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
|
||||
"edition__sort_name"
|
||||
),
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
@ -88,7 +108,7 @@ class PurchaseForm(forms.ModelForm):
|
|||
}
|
||||
model = Purchase
|
||||
fields = [
|
||||
"edition",
|
||||
"games",
|
||||
"platform",
|
||||
"date_purchased",
|
||||
"date_refunded",
|
||||
|
@ -140,24 +160,21 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
|||
return obj.sort_name
|
||||
|
||||
|
||||
class EditionForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
class GameForm(forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Edition
|
||||
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Game
|
||||
fields = ["name", "sort_name", "year_released", "wikidata"]
|
||||
fields = [
|
||||
"name",
|
||||
"sort_name",
|
||||
"platform",
|
||||
"year_released",
|
||||
"status",
|
||||
"wikidata",
|
||||
]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from .device import Query as DeviceQuery
|
||||
from .edition import Query as EditionQuery
|
||||
from .game import Query as GameQuery
|
||||
from .platform import Query as PlatformQuery
|
||||
from .purchase import Query as PurchaseQuery
|
||||
|
|
|
@ -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()
|
|
@ -1,5 +1,6 @@
|
|||
# Generated by Django 4.1.4 on 2023-01-02 18:27
|
||||
# Generated by Django 5.1.5 on 2025-01-29 21:26
|
||||
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
@ -8,94 +9,96 @@ class Migration(migrations.Migration):
|
|||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
name='Device',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("wikidata", models.CharField(max_length=50)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Platform",
|
||||
name='Platform',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("group", models.CharField(max_length=255)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('icon', models.SlugField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Purchase",
|
||||
name='ExchangeRate',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date_purchased", models.DateField()),
|
||||
("date_refunded", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('currency_from', models.CharField(max_length=255)),
|
||||
('currency_to', models.CharField(max_length=255)),
|
||||
('year', models.PositiveIntegerField()),
|
||||
('rate', models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('currency_from', 'currency_to', 'year')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Game',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('year_released', models.IntegerField(blank=True, default=None, null=True)),
|
||||
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('name', 'platform', 'year_released')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Purchase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date_purchased', models.DateField()),
|
||||
('date_refunded', models.DateField(blank=True, null=True)),
|
||||
('date_finished', models.DateField(blank=True, null=True)),
|
||||
('date_dropped', models.DateField(blank=True, null=True)),
|
||||
('infinite', models.BooleanField(default=False)),
|
||||
('price', models.FloatField(default=0)),
|
||||
('price_currency', models.CharField(default='USD', max_length=3)),
|
||||
('converted_price', models.FloatField(null=True)),
|
||||
('converted_currency', models.CharField(max_length=3, null=True)),
|
||||
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
|
||||
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
|
||||
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
|
||||
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Session",
|
||||
name='Session',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("timestamp_start", models.DateTimeField()),
|
||||
("timestamp_end", models.DateTimeField()),
|
||||
("duration_manual", models.DurationField(blank=True, null=True)),
|
||||
("duration_calculated", models.DurationField(blank=True, null=True)),
|
||||
("note", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"purchase",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp_start', models.DateTimeField()),
|
||||
('timestamp_end', models.DateTimeField(blank=True, null=True)),
|
||||
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
|
||||
('duration_calculated', models.DurationField(blank=True, null=True)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('emulated', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('modified_at', models.DateTimeField(auto_now=True)),
|
||||
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
|
||||
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'timestamp_start',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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,
|
||||
)
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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,
|
||||
)
|
||||
]
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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)]
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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")},
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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")},
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
138
games/models.py
138
games/models.py
|
@ -3,25 +3,68 @@ from datetime import timedelta
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Sum
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.template.defaultfilters import floatformat, pluralize, slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from common.time import format_duration
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
class Meta:
|
||||
unique_together = [["name", "platform", "year_released"]]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||
platform = models.ForeignKey(
|
||||
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Status(models.TextChoices):
|
||||
UNPLAYED = (
|
||||
"u",
|
||||
"Unplayed",
|
||||
)
|
||||
PLAYED = (
|
||||
"p",
|
||||
"Played",
|
||||
)
|
||||
FINISHED = (
|
||||
"f",
|
||||
"Finished",
|
||||
)
|
||||
RETIRED = (
|
||||
"r",
|
||||
"Retired",
|
||||
)
|
||||
ABANDONED = (
|
||||
"a",
|
||||
"Abandoned",
|
||||
)
|
||||
|
||||
status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED)
|
||||
mastered = models.BooleanField(default=False)
|
||||
|
||||
session_average: float | int | timedelta | None
|
||||
session_count: int | None
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.platform is None:
|
||||
self.platform = get_sentinel_platform()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def get_sentinel_platform():
|
||||
return Platform.objects.get_or_create(
|
||||
name="Unspecified", icon="unspecified", group="Unspecified"
|
||||
)[0]
|
||||
|
||||
|
||||
class Platform(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
|
@ -38,35 +81,6 @@ class Platform(models.Model):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def get_sentinel_platform():
|
||||
return Platform.objects.get_or_create(
|
||||
name="Unspecified", icon="unspecified", group="Unspecified"
|
||||
)[0]
|
||||
|
||||
|
||||
class Edition(models.Model):
|
||||
class Meta:
|
||||
unique_together = [["name", "platform", "year_released"]]
|
||||
|
||||
game = models.ForeignKey(Game, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||
platform = models.ForeignKey(
|
||||
Platform, on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
||||
)
|
||||
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.sort_name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.platform is None:
|
||||
self.platform = get_sentinel_platform()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PurchaseQueryset(models.QuerySet):
|
||||
def refunded(self):
|
||||
return self.filter(date_refunded__isnull=False)
|
||||
|
@ -113,7 +127,8 @@ class Purchase(models.Model):
|
|||
|
||||
objects = PurchaseQueryset().as_manager()
|
||||
|
||||
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
|
||||
games = models.ManyToManyField(Game, related_name="purchases", blank=True)
|
||||
|
||||
platform = models.ForeignKey(
|
||||
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
|
||||
)
|
||||
|
@ -126,6 +141,8 @@ class Purchase(models.Model):
|
|||
price_currency = models.CharField(max_length=3, default="USD")
|
||||
converted_price = models.FloatField(null=True)
|
||||
converted_currency = models.CharField(max_length=3, null=True)
|
||||
price_per_game = models.FloatField(null=True)
|
||||
num_purchases = models.IntegerField(default=0)
|
||||
ownership_type = models.CharField(
|
||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||
)
|
||||
|
@ -140,27 +157,49 @@ class Purchase(models.Model):
|
|||
related_name="related_purchases",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def standardized_price(self):
|
||||
return (
|
||||
f"{floatformat(self.converted_price, 0)} {self.converted_currency}"
|
||||
if self.converted_price
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def has_one_item(self):
|
||||
return self.games.count() == 1
|
||||
|
||||
@property
|
||||
def standardized_name(self):
|
||||
return self.name or self.first_game.name
|
||||
|
||||
@property
|
||||
def first_game(self):
|
||||
return self.games.first()
|
||||
|
||||
def __str__(self):
|
||||
return self.standardized_name
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
additional_info = [
|
||||
self.get_type_display() if self.type != Purchase.GAME else "",
|
||||
(
|
||||
f"{self.edition.platform} version on {self.platform}"
|
||||
if self.platform != self.edition.platform
|
||||
else self.platform
|
||||
),
|
||||
self.edition.year_released,
|
||||
self.get_ownership_type_display(),
|
||||
str(item)
|
||||
for item in [
|
||||
f"{self.num_purchases} game{pluralize(self.num_purchases)}",
|
||||
self.date_purchased,
|
||||
self.standardized_price,
|
||||
]
|
||||
if item
|
||||
]
|
||||
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
|
||||
return f"{self.standardized_name} ({', '.join(additional_info)})"
|
||||
|
||||
def is_game(self):
|
||||
return self.type == self.GAME
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.type == Purchase.GAME:
|
||||
self.name = ""
|
||||
elif self.type != Purchase.GAME and not self.related_purchase:
|
||||
if self.type != Purchase.GAME and not self.related_purchase:
|
||||
raise ValidationError(
|
||||
f"{self.get_type_display()} must have a related purchase."
|
||||
)
|
||||
|
@ -205,7 +244,14 @@ class Session(models.Model):
|
|||
class Meta:
|
||||
get_latest_by = "timestamp_start"
|
||||
|
||||
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE)
|
||||
game = models.ForeignKey(
|
||||
Game,
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
related_name="sessions",
|
||||
)
|
||||
timestamp_start = models.DateTimeField()
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True)
|
||||
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
|
||||
|
@ -218,6 +264,8 @@ class Session(models.Model):
|
|||
default=None,
|
||||
)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
emulated = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
modified_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
@ -225,7 +273,7 @@ class Session(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
mark = ", manual" if self.is_manual() else ""
|
||||
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||
|
||||
def finish_now(self):
|
||||
self.timestamp_end = timezone.now()
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
from django.db.models.signals import m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from games.models import 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"])
|
|
@ -1299,6 +1299,10 @@ input:checked + .toggle-bg {
|
|||
bottom: 0px;
|
||||
}
|
||||
|
||||
.-left-3 {
|
||||
left: -0.75rem;
|
||||
}
|
||||
|
||||
.bottom-0 {
|
||||
bottom: 0px;
|
||||
}
|
||||
|
@ -1335,6 +1339,10 @@ input:checked + .toggle-bg {
|
|||
top: 0px;
|
||||
}
|
||||
|
||||
.top-2 {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.top-3 {
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
@ -1415,6 +1423,10 @@ input:checked + .toggle-bg {
|
|||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.mr-4 {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
@ -1488,6 +1500,10 @@ input:checked + .toggle-bg {
|
|||
height: 3rem;
|
||||
}
|
||||
|
||||
.h-2 {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.h-2\.5 {
|
||||
height: 0.625rem;
|
||||
}
|
||||
|
@ -1524,6 +1540,10 @@ input:checked + .toggle-bg {
|
|||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.w-2 {
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
.w-2\.5 {
|
||||
width: 0.625rem;
|
||||
}
|
||||
|
@ -1576,6 +1596,10 @@ input:checked + .toggle-bg {
|
|||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.max-w-xl {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.max-w-xs {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
@ -1652,6 +1676,14 @@ input:checked + .toggle-bg {
|
|||
resize: both;
|
||||
}
|
||||
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
@ -1696,6 +1728,10 @@ input:checked + .toggle-bg {
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
@ -1779,6 +1815,10 @@ input:checked + .toggle-bg {
|
|||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.rounded-e-lg {
|
||||
border-start-end-radius: 0.5rem;
|
||||
border-end-end-radius: 0.5rem;
|
||||
|
@ -1872,6 +1912,11 @@ input:checked + .toggle-bg {
|
|||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-800 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
|
@ -1881,6 +1926,11 @@ input:checked + .toggle-bg {
|
|||
background-color: rgb(17 24 39 / 0.5);
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(14 159 110 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-green-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(5 122 85 / var(--tw-bg-opacity));
|
||||
|
@ -1891,6 +1941,21 @@ input:checked + .toggle-bg {
|
|||
background-color: rgb(4 108 78 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-orange-400 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 138 76 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purple-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(144 97 249 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(240 82 82 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(200 30 30 / var(--tw-bg-opacity));
|
||||
|
@ -2169,6 +2234,11 @@ input:checked + .toggle-bg {
|
|||
color: rgb(203 213 225 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(100 116 139 / var(--tw-text-opacity));
|
||||
|
@ -2192,6 +2262,10 @@ input:checked + .toggle-bg {
|
|||
text-decoration-color: #64748b;
|
||||
}
|
||||
|
||||
.decoration-dotted {
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
@ -2337,10 +2411,9 @@ input:checked + .toggle-bg {
|
|||
transition: all 0.2s ease-out;
|
||||
} */
|
||||
|
||||
form label:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity));
|
||||
}
|
||||
/* form label {
|
||||
@apply dark:text-slate-400;
|
||||
} */
|
||||
|
||||
.responsive-table {
|
||||
margin-left: auto;
|
||||
|
@ -2379,25 +2452,25 @@ form label:is(.dark *) {
|
|||
border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
form input:is(.dark *),
|
||||
select:is(.dark *),
|
||||
textarea:is(.dark *) {
|
||||
border-width: 1px;
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(15 23 42 / var(--tw-border-opacity));
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(100 116 139 / var(--tw-bg-opacity));
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity));
|
||||
/* form input,
|
||||
select,
|
||||
textarea {
|
||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||
} */
|
||||
|
||||
form input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
form input:disabled:is(.dark *),
|
||||
select:disabled:is(.dark *),
|
||||
textarea:disabled:is(.dark *) {
|
||||
--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;
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity));
|
||||
color: rgb(100 116 139 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.errorlist {
|
||||
|
@ -2413,21 +2486,21 @@ textarea:disabled:is(.dark *) {
|
|||
color: rgb(226 232 240 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
/* @media screen and (min-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
} */
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
/* @media screen and (max-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
} */
|
||||
|
||||
#button-container button {
|
||||
margin-left: 0.25rem;
|
||||
|
@ -2536,6 +2609,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) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||
|
@ -3117,6 +3231,10 @@ textarea:disabled:is(.dark *) {
|
|||
flex-direction: row;
|
||||
}
|
||||
|
||||
.md\:justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.md\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(2rem * var(--tw-space-x-reverse));
|
||||
|
@ -3260,3 +3378,13 @@ textarea:disabled:is(.dark *) {
|
|||
.\[\&_td\:last-child\]\:text-right td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media not all and (min-width: 640px) {
|
||||
.\[\&_td\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden td:not(:first-child):not(:last-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.\[\&_th\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden th:not(:first-child):not(:last-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_edition",
|
||||
source: "#id_games",
|
||||
source_value: "dataset.platform",
|
||||
target: "#id_platform",
|
||||
target_value: "value",
|
||||
|
@ -36,8 +36,8 @@ getEl("#id_type").onchange = () => {
|
|||
|
||||
document.body.addEventListener("htmx:beforeRequest", function (event) {
|
||||
// Assuming 'Purchase1' is the element that triggers the HTMX request
|
||||
if (event.target.id === "id_edition") {
|
||||
var idEditionValue = document.getElementById("id_edition").value;
|
||||
if (event.target.id === "id_games") {
|
||||
var idEditionValue = document.getElementById("id_games").value;
|
||||
|
||||
// Condition to check - replace this with your actual logic
|
||||
if (idEditionValue != "") {
|
||||
|
|
|
@ -36,7 +36,7 @@ function addToggleButton(targetNode) {
|
|||
targetNode.parentElement.appendChild(manualToggleButton);
|
||||
}
|
||||
|
||||
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
|
||||
const toggleableFields = ["#id_games", "#id_platform"];
|
||||
|
||||
toggleableFields.map((selector) => {
|
||||
addToggleButton(document.querySelector(selector));
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
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
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
|
@ -32,26 +36,53 @@ def convert_prices():
|
|||
).first()
|
||||
|
||||
if not exchange_rate:
|
||||
print(
|
||||
f"Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
||||
)
|
||||
try:
|
||||
# this API endpoint only accepts lowercase currency string
|
||||
response = requests.get(
|
||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from}.json"
|
||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
rate = data[currency_from].get(currency_to)
|
||||
currency_from_data = data.get(currency_from.lower())
|
||||
rate = currency_from_data.get(currency_to.lower())
|
||||
|
||||
if rate:
|
||||
print(f"Got {rate}, saving...")
|
||||
exchange_rate = ExchangeRate.objects.create(
|
||||
currency_from=currency_from,
|
||||
currency_to=currency_to,
|
||||
year=year,
|
||||
rate=rate,
|
||||
rate=floatformat(rate, 2),
|
||||
)
|
||||
else:
|
||||
print("Could not get an exchange rate.")
|
||||
except requests.RequestException as e:
|
||||
print(
|
||||
f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||
)
|
||||
if exchange_rate:
|
||||
save_converted_info(
|
||||
purchase, purchase.price * exchange_rate.rate, currency_to
|
||||
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)
|
||||
)
|
||||
print(f"Updating {purchases.count()} purchases.")
|
||||
purchases.update(
|
||||
price_per_game=ExpressionWrapper(
|
||||
F("converted_price") / F("num_purchases"), output_field=FloatField()
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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>
|
|
@ -1,12 +1,7 @@
|
|||
<c-layouts.add>
|
||||
<c-slot name="additional_row">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
<input type="submit"
|
||||
name="submit_and_redirect"
|
||||
value="Submit & Create Edition" />
|
||||
</td>
|
||||
</tr>
|
||||
<input type="submit"
|
||||
name="submit_and_redirect"
|
||||
value="Submit & Create Purchase" />
|
||||
</c-slot>
|
||||
</c-layouts.add>
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
<div class="relative ml-3">
|
||||
<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 %}
|
||||
"> </span>
|
||||
{{ slot }}
|
||||
</div>
|
|
@ -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>
|
|
@ -3,19 +3,18 @@
|
|||
{% if form_content %}
|
||||
{{ form_content }}
|
||||
{% else %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<table class="mx-auto">
|
||||
<div class="max-width-container">
|
||||
<div class="form-container max-w-xl mx-auto">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_table }}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
<input type="submit" value="Submit" />
|
||||
</td>
|
||||
</tr>
|
||||
{{ additional_row }}
|
||||
</table>
|
||||
</form>
|
||||
{{ form.as_div }}
|
||||
<div><input type="submit" value="Submit" /></div>
|
||||
<div class="submit-button-container">
|
||||
{{ additional_row }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<c-slot name="scripts">
|
||||
{% if script_name %}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
|
||||
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span>
|
||||
<div data-popover
|
||||
id="{{ id }}"
|
||||
role="tooltip"
|
||||
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
|
||||
<div class="px-3 py-2">{{ popover_content }}</div>
|
||||
<div data-popper-arrow></div>
|
||||
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
|
||||
<span class="hidden decoration-dotted"></span>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<span title="Price is a result of conversion and rounding." class="decoration-dotted underline">{{ slot }}</span>
|
|
@ -7,20 +7,20 @@
|
|||
{{ header_action }}
|
||||
</c-table-header>
|
||||
{% endif %}
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden">
|
||||
<tr>
|
||||
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="dark:divide-y">
|
||||
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden">
|
||||
{% for row in rows %}<c-table-row :data=row />{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if page_obj and elided_page_range %}
|
||||
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
|
||||
<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
|
||||
aria-label="Table navigation">
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span>—<span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
|
||||
<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto"><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span>—<span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
|
||||
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||
<li>
|
||||
{% if page_obj.has_previous %}
|
||||
|
|
|
@ -36,8 +36,8 @@
|
|||
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
|
||||
<span class="inline-block relative">
|
||||
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
|
||||
href="{% url 'view_game' session.purchase.edition.game.id %}">
|
||||
{{ session.purchase.edition.name }}
|
||||
href="{% url 'view_game' session.game.id %}">
|
||||
{{ session.game.name }}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
|
|
|
@ -25,7 +25,11 @@
|
|||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
<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"
|
||||
|
@ -57,10 +61,6 @@
|
|||
<a href="{% url 'add_game' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'add_edition' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Edition</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'add_platform' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
|
||||
|
@ -102,10 +102,6 @@
|
|||
<a href="{% url 'list_games' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'list_editions' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'list_platforms' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
{% load static %}
|
||||
{% partialdef purchase-name %}
|
||||
{% if purchase.type != 'game' %}
|
||||
<c-gamelink :game_id=purchase.edition.game.id>
|
||||
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
|
||||
<c-gamelink :game_id=purchase.first_game.id>
|
||||
{{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
|
||||
</c-gamelink>
|
||||
{% else %}
|
||||
<c-gamelink :game_id=purchase.edition.game.id :name=purchase.edition.name />
|
||||
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
|
||||
{% endif %}
|
||||
{% endpartialdef %}
|
||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
||||
|
@ -46,7 +46,7 @@
|
|||
{% endif %}
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_year_games }}</td>
|
||||
</tr>
|
||||
{% if all_finished_this_year_count %}
|
||||
<tr>
|
||||
|
@ -100,7 +100,7 @@
|
|||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if month_playtime %}
|
||||
{% if month_playtimes %}
|
||||
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
|
||||
<table class="responsive-table">
|
||||
<tbody>
|
||||
|
@ -142,11 +142,13 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||
{{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -253,7 +255,7 @@
|
|||
{% for purchase in purchased_unfinished %}
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price }}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@ -274,7 +276,7 @@
|
|||
{% for purchase in all_purchased_this_year %}
|
||||
<tr>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price }}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div id="game-info" class="mb-10">
|
||||
<div class="flex gap-5 mb-3">
|
||||
<span class="text-balance max-w-[30rem] text-4xl">
|
||||
<span class="font-bold font-serif">{{ game.name }}</span> <c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>
|
||||
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %} <c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-4 dark:text-slate-400 mb-3">
|
||||
|
@ -52,6 +52,18 @@
|
|||
{{ playrange }}
|
||||
</c-popover>
|
||||
</div>
|
||||
<div class="mb-6 text-slate-400">
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase font-bold text-slate-300">Status</span>
|
||||
<c-gamestatus :status="game.status">
|
||||
{{ game.get_status_display }}
|
||||
</c-gamestatus>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase font-bold text-slate-300">Platform</span>
|
||||
<span>{{ game.platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
|
||||
<a href="{% url 'edit_game' game.id %}">
|
||||
<button type="button"
|
||||
|
@ -67,17 +79,21 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<c-h1 :badge="edition_count">Editions</c-h1>
|
||||
<div class="mb-6">
|
||||
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<c-h1 :badge="purchase_count">Purchases</c-h1>
|
||||
{% if purchase_count %}
|
||||
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />
|
||||
{% else %}
|
||||
No purchases yet.
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<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 />
|
||||
{% else %}
|
||||
No sessions yet.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
<c-layouts.base>
|
||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
||||
|
||||
<div class="flex flex-col gap-5 mb-3">
|
||||
<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="font-bold font-serif">
|
||||
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
|
||||
</span>
|
||||
</span>
|
||||
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
|
||||
<a href="{% url 'edit_purchase' purchase.id %}">
|
||||
<button type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||
Edit
|
||||
</button>
|
||||
</a>
|
||||
<a href="{% url 'delete_purchase' purchase.id %}">
|
||||
<button type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||
Delete
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<h2 class="text-base">Items:</h2>
|
||||
<ul class="list-disc list-inside">
|
||||
{% for game in purchase.games.all %}
|
||||
<li><c-gamelink :game_id=game.id :name=game.name /></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</c-layouts.base>
|
|
@ -1,6 +1,6 @@
|
|||
from django.urls import path
|
||||
|
||||
from games.views import device, edition, game, general, platform, purchase, session
|
||||
from games.views import device, game, general, platform, purchase, session
|
||||
|
||||
urlpatterns = [
|
||||
path("", general.index, name="index"),
|
||||
|
@ -8,19 +8,6 @@ urlpatterns = [
|
|||
path("device/delete/<int:device_id>", device.delete_device, name="delete_device"),
|
||||
path("device/edit/<int:device_id>", device.edit_device, name="edit_device"),
|
||||
path("device/list", device.list_devices, name="list_devices"),
|
||||
path("edition/add", edition.add_edition, name="add_edition"),
|
||||
path(
|
||||
"edition/add/for-game/<int:game_id>",
|
||||
edition.add_edition,
|
||||
name="add_edition_for_game",
|
||||
),
|
||||
path("edition/<int:edition_id>/edit", edition.edit_edition, name="edit_edition"),
|
||||
path("edition/list", edition.list_editions, name="list_editions"),
|
||||
path(
|
||||
"edition/<int:edition_id>/delete",
|
||||
edition.delete_edition,
|
||||
name="delete_edition",
|
||||
),
|
||||
path("game/add", game.add_game, name="add_game"),
|
||||
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
|
||||
path("game/<int:game_id>/view", game.view_game, name="view_game"),
|
||||
|
@ -39,6 +26,11 @@ urlpatterns = [
|
|||
),
|
||||
path("platform/list", platform.list_platforms, name="list_platforms"),
|
||||
path("purchase/add", purchase.add_purchase, name="add_purchase"),
|
||||
path(
|
||||
"purchase/add/for-game/<int:game_id>",
|
||||
purchase.add_purchase,
|
||||
name="add_purchase_for_game",
|
||||
),
|
||||
path(
|
||||
"purchase/<int:purchase_id>/edit",
|
||||
purchase.edit_purchase,
|
||||
|
@ -54,6 +46,11 @@ urlpatterns = [
|
|||
purchase.delete_purchase,
|
||||
name="delete_purchase",
|
||||
),
|
||||
path(
|
||||
"purchase/<int:purchase_id>/view",
|
||||
purchase.view_purchase,
|
||||
name="view_purchase",
|
||||
),
|
||||
path(
|
||||
"purchase/<int:purchase_id>/finish",
|
||||
purchase.finish_purchase,
|
||||
|
@ -70,20 +67,15 @@ urlpatterns = [
|
|||
name="refund_purchase",
|
||||
),
|
||||
path(
|
||||
"purchase/related-purchase-by-edition",
|
||||
purchase.related_purchase_by_edition,
|
||||
name="related_purchase_by_edition",
|
||||
),
|
||||
path(
|
||||
"purchase/add/for-edition/<int:edition_id>",
|
||||
purchase.add_purchase,
|
||||
name="add_purchase_for_edition",
|
||||
"purchase/related-purchase-by-game",
|
||||
purchase.related_purchase_by_game,
|
||||
name="related_purchase_by_game",
|
||||
),
|
||||
path("session/add", session.add_session, name="add_session"),
|
||||
path(
|
||||
"session/add/for-purchase/<int:purchase_id>",
|
||||
"session/add/for-game/<int:game_id>",
|
||||
session.add_session,
|
||||
name="add_session_for_purchase",
|
||||
name="add_session_for_game",
|
||||
),
|
||||
path(
|
||||
"session/add/from-game/<int:session_id>",
|
||||
|
|
|
@ -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)
|
|
@ -2,7 +2,7 @@ from typing import Any
|
|||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.loader import render_to_string
|
||||
|
@ -12,10 +12,13 @@ from common.components import (
|
|||
A,
|
||||
Button,
|
||||
Div,
|
||||
Form,
|
||||
Icon,
|
||||
NameWithPlatformIcon,
|
||||
LinkedPurchase,
|
||||
NameWithIcon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
PurchasePrice,
|
||||
)
|
||||
from common.time import (
|
||||
dateformat,
|
||||
|
@ -25,19 +28,39 @@ from common.time import (
|
|||
local_strftime,
|
||||
timeformat,
|
||||
)
|
||||
from common.utils import format_float_or_int, safe_division, truncate
|
||||
from common.utils import build_dynamic_filter, safe_division, truncate
|
||||
from games.forms import GameForm
|
||||
from games.models import Edition, Game, Purchase, Session
|
||||
from games.models import Game, Purchase
|
||||
from games.views.general import use_custom_redirect
|
||||
|
||||
|
||||
@login_required
|
||||
def list_games(request: HttpRequest) -> HttpResponse:
|
||||
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
context: dict[Any, Any] = {}
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
games = Game.objects.order_by("-created_at")
|
||||
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:
|
||||
paginator = Paginator(games, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
@ -54,35 +77,45 @@ def list_games(request: HttpRequest) -> HttpResponse:
|
|||
else None
|
||||
),
|
||||
"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": [
|
||||
"Name",
|
||||
"Sort Name",
|
||||
"Year",
|
||||
"Status",
|
||||
"Wikidata",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
A(
|
||||
[
|
||||
(
|
||||
"href",
|
||||
reverse(
|
||||
"view_game",
|
||||
args=[game.pk],
|
||||
),
|
||||
)
|
||||
],
|
||||
PopoverTruncated(game.name),
|
||||
),
|
||||
NameWithIcon(game_id=game.pk),
|
||||
PopoverTruncated(
|
||||
game.sort_name
|
||||
if game.sort_name is not None and game.name != game.sort_name
|
||||
else "(identical)"
|
||||
),
|
||||
game.year_released,
|
||||
render_to_string(
|
||||
"cotton/gamestatus.html",
|
||||
{"status": game.status, "slot": game.get_status_display()},
|
||||
),
|
||||
game.wikidata,
|
||||
local_strftime(game.created_at, dateformat),
|
||||
render_to_string(
|
||||
|
@ -118,7 +151,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||
game = form.save()
|
||||
if "submit_and_redirect" in request.POST:
|
||||
return HttpResponseRedirect(
|
||||
reverse("add_edition_for_game", kwargs={"game_id": game.id})
|
||||
reverse("add_purchase_for_game", kwargs={"game_id": game.id})
|
||||
)
|
||||
else:
|
||||
return redirect("list_games")
|
||||
|
@ -161,29 +194,20 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
to_attr="nongame_related_purchases",
|
||||
)
|
||||
game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
|
||||
"purchase_set",
|
||||
"purchases",
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
|
||||
nongame_related_purchases_prefetch
|
||||
),
|
||||
to_attr="game_purchases",
|
||||
)
|
||||
editions = (
|
||||
Edition.objects.filter(game=game)
|
||||
.prefetch_related(game_purchases_prefetch)
|
||||
.order_by("year_released")
|
||||
)
|
||||
|
||||
purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased")
|
||||
purchases = game.purchases.order_by("date_purchased")
|
||||
|
||||
sessions = Session.objects.prefetch_related("device").filter(
|
||||
purchase__edition__game=game
|
||||
)
|
||||
sessions = game.sessions
|
||||
session_count = sessions.count()
|
||||
session_count_without_manual = (
|
||||
Session.objects.without_manual().filter(purchase__edition__game=game).count()
|
||||
)
|
||||
session_count_without_manual = game.sessions.without_manual().count()
|
||||
|
||||
if sessions:
|
||||
if sessions.exists():
|
||||
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
|
||||
latest_session = sessions.latest()
|
||||
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
|
||||
|
@ -202,52 +226,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
|
||||
)
|
||||
|
||||
edition_data: dict[str, Any] = {
|
||||
"columns": [
|
||||
"Name",
|
||||
"Year Released",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
NameWithPlatformIcon(
|
||||
name=edition.name,
|
||||
platform=edition.platform,
|
||||
),
|
||||
edition.year_released,
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
"buttons": [
|
||||
{
|
||||
"href": reverse("edit_edition", args=[edition.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("delete_edition", args=[edition.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
]
|
||||
for edition in editions
|
||||
],
|
||||
}
|
||||
|
||||
purchase_data: dict[str, Any] = {
|
||||
"columns": ["Name", "Type", "Date", "Price", "Actions"],
|
||||
"rows": [
|
||||
[
|
||||
NameWithPlatformIcon(
|
||||
name=purchase.name if purchase.name else purchase.edition.name,
|
||||
platform=purchase.platform,
|
||||
),
|
||||
LinkedPurchase(purchase),
|
||||
purchase.get_type_display(),
|
||||
purchase.date_purchased.strftime(dateformat),
|
||||
f"{format_float_or_int(purchase.price)} {purchase.price_currency}",
|
||||
PurchasePrice(purchase),
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
|
@ -270,9 +256,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
],
|
||||
}
|
||||
|
||||
sessions_all = Session.objects.filter(purchase__edition__game=game).order_by(
|
||||
"-timestamp_start"
|
||||
)
|
||||
sessions_all = game.sessions.order_by("-timestamp_start")
|
||||
|
||||
last_session = None
|
||||
if sessions_all.exists():
|
||||
last_session = sessions_all.latest()
|
||||
|
@ -299,7 +284,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
args=[last_session.pk],
|
||||
),
|
||||
children=Popover(
|
||||
popover_content=last_session.purchase.edition.name,
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
icon=True,
|
||||
|
@ -307,7 +292,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
size="xs",
|
||||
children=[
|
||||
Icon("play"),
|
||||
truncate(f"{last_session.purchase.edition.name}"),
|
||||
truncate(f"{last_session.game.name}"),
|
||||
],
|
||||
)
|
||||
],
|
||||
|
@ -317,16 +302,13 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
else "",
|
||||
],
|
||||
),
|
||||
"columns": ["Edition", "Date", "Duration", "Actions"],
|
||||
"columns": ["Game", "Date", "Duration", "Actions"],
|
||||
"rows": [
|
||||
[
|
||||
NameWithPlatformIcon(
|
||||
name=session.purchase.name
|
||||
if session.purchase.name
|
||||
else session.purchase.edition.name,
|
||||
platform=session.purchase.platform,
|
||||
NameWithIcon(
|
||||
session_id=session.pk,
|
||||
),
|
||||
f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
|
||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||
(
|
||||
format_duration(session.duration_calculated, durationformat)
|
||||
if session.duration_calculated
|
||||
|
@ -370,11 +352,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
}
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"edition_count": editions.count(),
|
||||
"editions": editions,
|
||||
"game": game,
|
||||
"playrange": playrange,
|
||||
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
|
||||
"purchase_count": game.purchases.count(),
|
||||
"session_average_without_manual": round(
|
||||
safe_division(
|
||||
total_hours_without_manual, int(session_count_without_manual)
|
||||
|
@ -385,7 +365,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||
"sessions": sessions,
|
||||
"title": f"Game Overview - {game.name}",
|
||||
"hours_sum": total_hours,
|
||||
"edition_data": edition_data,
|
||||
"purchase_data": purchase_data,
|
||||
"session_data": session_data,
|
||||
"session_page_obj": session_page_obj,
|
||||
|
|
|
@ -1,26 +1,39 @@
|
|||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields
|
||||
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
|
||||
from django.db.models.functions import TruncDate, TruncMonth
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
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.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]:
|
||||
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")))["time"]
|
||||
last_7_played = Session.objects.filter(
|
||||
timestamp_start__gte=(now - timedelta(days=7))
|
||||
).aggregate(time=Sum(F("duration_calculated")))["time"]
|
||||
|
||||
return {
|
||||
"game_available": Game.objects.exists(),
|
||||
"edition_available": Edition.objects.exists(),
|
||||
"platform_available": Platform.objects.exists(),
|
||||
"purchase_available": Purchase.objects.exists(),
|
||||
"session_count": Session.objects.exists(),
|
||||
"today_played": format_duration(today_played, "%H h %m m"),
|
||||
"last_7_played": format_duration(last_7_played, "%H h %m m"),
|
||||
}
|
||||
|
||||
|
||||
|
@ -49,7 +62,7 @@ def use_custom_redirect(
|
|||
@login_required
|
||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||
year = "Alltime"
|
||||
this_year_sessions = Session.objects.all().select_related("purchase__edition")
|
||||
this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
|
||||
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("timestamp_end") - F("timestamp_start"),
|
||||
|
@ -57,11 +70,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
)
|
||||
)
|
||||
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||
this_year_games = Game.objects.filter(
|
||||
edition__purchase__session__in=this_year_sessions
|
||||
).distinct()
|
||||
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
|
||||
this_year_games_with_session_counts = this_year_games.annotate(
|
||||
session_count=Count("edition__purchase__session"),
|
||||
session_count=Count("sessions"),
|
||||
)
|
||||
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||
"-session_count"
|
||||
|
@ -74,11 +85,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
.aggregate(dates=Count("date"))
|
||||
)
|
||||
this_year_played_purchases = Purchase.objects.filter(
|
||||
session__in=this_year_sessions
|
||||
games__sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.all()
|
||||
this_year_purchases_with_currency = this_year_purchases.select_related("edition")
|
||||
this_year_purchases_with_currency = this_year_purchases.select_related("games")
|
||||
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
||||
date_refunded=None
|
||||
)
|
||||
|
@ -127,11 +138,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(
|
||||
total_playtime=Sum(
|
||||
F("edition__purchase__session__duration_calculated")
|
||||
+ F("edition__purchase__session__duration_manual")
|
||||
F("sessions__duration_calculated") + F("sessions__duration_manual")
|
||||
)
|
||||
)
|
||||
.values("id", "name", "total_playtime")
|
||||
|
@ -146,10 +156,8 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
month["playtime"] = format_duration(month["playtime"], "%2.0H")
|
||||
|
||||
highest_session_average_game = (
|
||||
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||
.annotate(
|
||||
session_average=Avg("edition__purchase__session__duration_calculated")
|
||||
)
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(session_average=Avg("sessions__duration_calculated"))
|
||||
.order_by("-session_average")
|
||||
.first()
|
||||
)
|
||||
|
@ -158,9 +166,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
||||
|
||||
total_playtime_per_platform = (
|
||||
this_year_sessions.values("purchase__platform__name")
|
||||
this_year_sessions.values("game__platform__name")
|
||||
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
||||
.annotate(platform_name=F("purchase__platform__name"))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "total_playtime")
|
||||
.order_by("-total_playtime")
|
||||
)
|
||||
|
@ -175,10 +183,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
last_play_date = "N/A"
|
||||
if this_year_sessions:
|
||||
first_session = this_year_sessions.earliest()
|
||||
first_play_game = first_session.purchase.edition.game
|
||||
first_play_game = first_session.game
|
||||
first_play_date = first_session.timestamp_start.strftime(dateformat)
|
||||
last_session = this_year_sessions.latest()
|
||||
last_play_game = last_session.purchase.edition.game
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases_with_currency.count()
|
||||
|
@ -193,7 +201,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
"total_hours": format_duration(
|
||||
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,
|
||||
"year": year,
|
||||
"total_playtime_per_platform": total_playtime_per_platform,
|
||||
|
@ -226,9 +234,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||
if longest_session
|
||||
else 0
|
||||
),
|
||||
"longest_session_game": (
|
||||
longest_session.purchase.edition.game if longest_session else None
|
||||
),
|
||||
"longest_session_game": (longest_session.game if longest_session else None),
|
||||
"highest_session_count": (
|
||||
game_highest_session_count.session_count
|
||||
if game_highest_session_count
|
||||
|
@ -266,7 +272,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
return HttpResponseRedirect(reverse("stats_alltime"))
|
||||
this_year_sessions = Session.objects.filter(
|
||||
timestamp_start__year=year
|
||||
).select_related("purchase__edition")
|
||||
).prefetch_related("game")
|
||||
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("timestamp_end") - F("timestamp_start"),
|
||||
|
@ -274,13 +280,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
)
|
||||
)
|
||||
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||
this_year_games = Game.objects.filter(
|
||||
edition__purchase__session__in=this_year_sessions
|
||||
).distinct()
|
||||
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
|
||||
this_year_games_with_session_counts = this_year_games.annotate(
|
||||
session_count=Count(
|
||||
"edition__purchase__session",
|
||||
filter=Q(edition__purchase__session__timestamp_start__year=year),
|
||||
"sessions",
|
||||
filter=Q(sessions__timestamp_start__year=year),
|
||||
)
|
||||
)
|
||||
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||
|
@ -294,11 +298,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
.aggregate(dates=Count("date"))
|
||||
)
|
||||
this_year_played_purchases = Purchase.objects.filter(
|
||||
session__in=this_year_sessions
|
||||
games__sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_played_games = Game.objects.filter(
|
||||
sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
|
||||
this_year_purchases_with_currency = this_year_purchases.select_related("edition")
|
||||
this_year_purchases_with_currency = this_year_purchases.prefetch_related("games")
|
||||
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
||||
date_refunded=None
|
||||
).exclude(ownership_type=Purchase.DEMO)
|
||||
|
@ -335,7 +343,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
|
||||
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
|
||||
purchases_finished_this_year_released_this_year = (
|
||||
purchases_finished_this_year.filter(edition__year_released=year).order_by(
|
||||
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
||||
"date_finished"
|
||||
)
|
||||
)
|
||||
|
@ -349,11 +357,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(
|
||||
total_playtime=Sum(
|
||||
F("edition__purchase__session__duration_calculated")
|
||||
+ F("edition__purchase__session__duration_manual")
|
||||
F("sessions__duration_calculated") + F("sessions__duration_manual")
|
||||
)
|
||||
)
|
||||
.values("id", "name", "total_playtime")
|
||||
|
@ -368,21 +375,19 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
month["playtime"] = format_duration(month["playtime"], "%2.0H")
|
||||
|
||||
highest_session_average_game = (
|
||||
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||
.annotate(
|
||||
session_average=Avg("edition__purchase__session__duration_calculated")
|
||||
)
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(session_average=Avg("sessions__duration_calculated"))
|
||||
.order_by("-session_average")
|
||||
.first()
|
||||
)
|
||||
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:
|
||||
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
||||
|
||||
total_playtime_per_platform = (
|
||||
this_year_sessions.values("purchase__platform__name")
|
||||
this_year_sessions.values("game__platform__name")
|
||||
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
||||
.annotate(platform_name=F("purchase__platform__name"))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "total_playtime")
|
||||
.order_by("-total_playtime")
|
||||
)
|
||||
|
@ -401,10 +406,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
last_play_game = None
|
||||
if this_year_sessions:
|
||||
first_session = this_year_sessions.earliest()
|
||||
first_play_game = first_session.purchase.edition.game
|
||||
first_play_game = first_session.game
|
||||
first_play_date = first_session.timestamp_start.strftime(dateformat)
|
||||
last_session = this_year_sessions.latest()
|
||||
last_play_game = last_session.purchase.edition.game
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases_with_currency.count()
|
||||
|
@ -419,9 +424,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
"total_hours": format_duration(
|
||||
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||
),
|
||||
"total_games": this_year_played_purchases.count(),
|
||||
"total_2023_games": this_year_played_purchases.filter(
|
||||
edition__year_released=year
|
||||
"total_games": this_year_played_games.count(),
|
||||
"total_year_games": this_year_played_purchases.filter(
|
||||
games__year_released=year
|
||||
).count(),
|
||||
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||
"year": year,
|
||||
|
@ -432,16 +437,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
"spent_per_game": int(
|
||||
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||
),
|
||||
"all_finished_this_year": purchases_finished_this_year.select_related(
|
||||
"edition"
|
||||
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
"all_finished_this_year_count": purchases_finished_this_year.count(),
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
|
||||
"edition"
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related(
|
||||
"edition"
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
|
@ -470,9 +475,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||
if longest_session
|
||||
else 0
|
||||
),
|
||||
"longest_session_game": (
|
||||
longest_session.purchase.edition.game if longest_session else None
|
||||
),
|
||||
"longest_session_game": (longest_session.game if longest_session else None),
|
||||
"highest_session_count": (
|
||||
game_highest_session_count.session_count
|
||||
if game_highest_session_count
|
||||
|
|
|
@ -13,19 +13,10 @@ from django.template.loader import render_to_string
|
|||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from common.components import (
|
||||
BooleanRadioFieldset,
|
||||
Form,
|
||||
Icon,
|
||||
Input,
|
||||
LinkedNameWithPlatformIcon,
|
||||
SubmitButton,
|
||||
)
|
||||
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
|
||||
from common.time import dateformat
|
||||
from common.utils import format_float_or_int
|
||||
from games.filters import apply_filters, create_filter_form
|
||||
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
|
||||
|
||||
|
||||
|
@ -35,146 +26,119 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
|
||||
|
||||
filter_form = create_filter_form(
|
||||
"Purchase", ["infinite", "price", "price_currency"]
|
||||
)
|
||||
purchases = apply_filters(request, purchases)
|
||||
|
||||
test_form = Form(
|
||||
children=[
|
||||
BooleanRadioFieldset(
|
||||
name="filter__infinite",
|
||||
label="Infinite",
|
||||
),
|
||||
Input(
|
||||
label="Original currency",
|
||||
id="filter__price_currency",
|
||||
attributes=[("name", "filter__price_currency")],
|
||||
),
|
||||
SubmitButton("Apply"),
|
||||
]
|
||||
)
|
||||
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(purchases, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
purchases = page_obj.object_list
|
||||
|
||||
context = {
|
||||
"title": "Manage purchases",
|
||||
"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": filter_form,
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
"Price",
|
||||
"Currency",
|
||||
"Infinite",
|
||||
"Purchased",
|
||||
"Refunded",
|
||||
"Finished",
|
||||
"Dropped",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
LinkedNameWithPlatformIcon(
|
||||
name=purchase.edition.name,
|
||||
game_id=purchase.edition.game.pk,
|
||||
platform=purchase.platform,
|
||||
),
|
||||
purchase.get_type_display(),
|
||||
format_float_or_int(purchase.price),
|
||||
purchase.price_currency,
|
||||
purchase.infinite,
|
||||
purchase.date_purchased.strftime(dateformat),
|
||||
(
|
||||
purchase.date_refunded.strftime(dateformat)
|
||||
if purchase.date_refunded
|
||||
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),
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
"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(
|
||||
"refund_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("refund"),
|
||||
"title": "Mark as refunded",
|
||||
}
|
||||
if not purchase.date_refunded
|
||||
else {},
|
||||
{
|
||||
"href": reverse(
|
||||
"edit_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("edit"),
|
||||
"title": "Edit",
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse(
|
||||
"delete_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"title": "Delete",
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
]
|
||||
for purchase in purchases
|
||||
],
|
||||
},
|
||||
}
|
||||
context = {
|
||||
"title": "Manage purchases",
|
||||
"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 purchase"), url="add_purchase"),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
"Price",
|
||||
"Infinite",
|
||||
"Purchased",
|
||||
"Refunded",
|
||||
"Finished",
|
||||
"Dropped",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
LinkedPurchase(purchase),
|
||||
purchase.get_type_display(),
|
||||
PurchasePrice(purchase),
|
||||
purchase.infinite,
|
||||
purchase.date_purchased.strftime(dateformat),
|
||||
(
|
||||
purchase.date_refunded.strftime(dateformat)
|
||||
if purchase.date_refunded
|
||||
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),
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
"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(
|
||||
"refund_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("refund"),
|
||||
"title": "Mark as refunded",
|
||||
}
|
||||
if not purchase.date_refunded
|
||||
else {},
|
||||
{
|
||||
"href": reverse(
|
||||
"edit_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("edit"),
|
||||
"title": "Edit",
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse(
|
||||
"delete_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"title": "Delete",
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
]
|
||||
for purchase in purchases
|
||||
],
|
||||
},
|
||||
}
|
||||
return render(request, "list_purchases.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
|
||||
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
context: dict[str, Any] = {}
|
||||
initial = {"date_purchased": timezone.now()}
|
||||
|
||||
|
@ -185,19 +149,20 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
|
|||
if "submit_and_redirect" in request.POST:
|
||||
return HttpResponseRedirect(
|
||||
reverse(
|
||||
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
|
||||
"add_session_for_game",
|
||||
kwargs={"game_id": purchase.first_game.id},
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect("list_purchases")
|
||||
else:
|
||||
if edition_id:
|
||||
edition = Edition.objects.get(id=edition_id)
|
||||
if game_id:
|
||||
game = Game.objects.get(id=game_id)
|
||||
form = PurchaseForm(
|
||||
initial={
|
||||
**initial,
|
||||
"edition": edition,
|
||||
"platform": edition.platform,
|
||||
"games": [game],
|
||||
"platform": game.platform,
|
||||
}
|
||||
)
|
||||
else:
|
||||
|
@ -205,7 +170,7 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
|
|||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Purchase"
|
||||
context["script_name"] = "add_purchase.js"
|
||||
# context["script_name"] = "add_purchase.js"
|
||||
return render(request, "add_purchase.html", context)
|
||||
|
||||
|
||||
|
@ -221,7 +186,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||
context["title"] = "Edit Purchase"
|
||||
context["form"] = form
|
||||
context["purchase_id"] = str(purchase_id)
|
||||
context["script_name"] = "add_purchase.js"
|
||||
# context["script_name"] = "add_purchase.js"
|
||||
return render(request, "add_purchase.html", context)
|
||||
|
||||
|
||||
|
@ -232,6 +197,16 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||
return redirect("list_purchases")
|
||||
|
||||
|
||||
@login_required
|
||||
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||
return render(
|
||||
request,
|
||||
"view_purchase.html",
|
||||
{"purchase": purchase, "title": f"Purchase: {purchase.full_name}"},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||
|
@ -256,12 +231,14 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||
return redirect("list_purchases")
|
||||
|
||||
|
||||
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
|
||||
edition_id = request.GET.get("edition")
|
||||
if not edition_id:
|
||||
return HttpResponseBadRequest("Invalid edition_id")
|
||||
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||
games = request.GET.getlist("games")
|
||||
if not games:
|
||||
return HttpResponseBadRequest("Invalid game_id")
|
||||
if isinstance(games, int) or isinstance(games, str):
|
||||
games = [games]
|
||||
form = PurchaseForm()
|
||||
form.fields["related_purchase"].queryset = Purchase.objects.filter(
|
||||
edition_id=edition_id, type=Purchase.GAME
|
||||
).order_by("edition__sort_name")
|
||||
games__in=games, type=Purchase.GAME
|
||||
).order_by("games__sort_name")
|
||||
return render(request, "partials/related_purchase_field.html", {"form": form})
|
||||
|
|
|
@ -15,7 +15,7 @@ from common.components import (
|
|||
Div,
|
||||
Form,
|
||||
Icon,
|
||||
LinkedNameWithPlatformIcon,
|
||||
NameWithIcon,
|
||||
Popover,
|
||||
)
|
||||
from common.time import (
|
||||
|
@ -28,7 +28,7 @@ from common.time import (
|
|||
)
|
||||
from common.utils import truncate
|
||||
from games.forms import SessionForm
|
||||
from games.models import Purchase, Session
|
||||
from games.models import Game, Session
|
||||
from games.views.general import use_custom_redirect
|
||||
|
||||
|
||||
|
@ -37,17 +37,20 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||
context: dict[Any, Any] = {}
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
sessions = Session.objects.order_by("-timestamp_start")
|
||||
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
||||
search_string = request.GET.get("search_string", search_string)
|
||||
if search_string != "":
|
||||
sessions = sessions.filter(
|
||||
Q(purchase__edition__name__icontains=search_string)
|
||||
| Q(purchase__edition__game__name__icontains=search_string)
|
||||
| Q(purchase__platform__name__icontains=search_string)
|
||||
Q(game__name__icontains=search_string)
|
||||
| Q(game__name__icontains=search_string)
|
||||
| Q(game__platform__name__icontains=search_string)
|
||||
| Q(device__name__icontains=search_string)
|
||||
| Q(device__type__icontains=search_string)
|
||||
)
|
||||
last_session = sessions.latest()
|
||||
try:
|
||||
last_session = sessions.latest()
|
||||
except Session.DoesNotExist:
|
||||
last_session = None
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(sessions, limit)
|
||||
|
@ -94,7 +97,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||
args=[last_session.pk],
|
||||
),
|
||||
children=Popover(
|
||||
popover_content=last_session.purchase.edition.name,
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
icon=True,
|
||||
|
@ -102,14 +105,14 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||
size="xs",
|
||||
children=[
|
||||
Icon("play"),
|
||||
truncate(
|
||||
f"{last_session.purchase.edition.name}"
|
||||
),
|
||||
truncate(f"{last_session.game.name}"),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
if last_session
|
||||
else "",
|
||||
]
|
||||
),
|
||||
],
|
||||
|
@ -125,12 +128,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||
],
|
||||
"rows": [
|
||||
[
|
||||
LinkedNameWithPlatformIcon(
|
||||
name=session.purchase.edition.name,
|
||||
game_id=session.purchase.edition.game.pk,
|
||||
platform=session.purchase.platform,
|
||||
),
|
||||
f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
|
||||
NameWithIcon(session_id=session.pk),
|
||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||
(
|
||||
format_duration(session.duration_calculated, durationformat)
|
||||
if session.duration_calculated
|
||||
|
@ -190,13 +189,13 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
|||
|
||||
|
||||
@login_required
|
||||
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
|
||||
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
context = {}
|
||||
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
|
||||
|
||||
last = Session.objects.last()
|
||||
if last != None:
|
||||
initial["purchase"] = last.purchase
|
||||
initial["game"] = last.game
|
||||
|
||||
if request.method == "POST":
|
||||
form = SessionForm(request.POST or None, initial=initial)
|
||||
|
@ -204,21 +203,22 @@ def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
|
|||
form.save()
|
||||
return redirect("list_sessions")
|
||||
else:
|
||||
if purchase_id:
|
||||
purchase = Purchase.objects.get(id=purchase_id)
|
||||
if game_id:
|
||||
game = Game.objects.get(id=game_id)
|
||||
form = SessionForm(
|
||||
initial={
|
||||
**initial,
|
||||
"purchase": purchase,
|
||||
"game": game,
|
||||
}
|
||||
)
|
||||
else:
|
||||
form = SessionForm(initial=initial)
|
||||
|
||||
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
|
||||
return render(request, "add_session.html", context)
|
||||
return render(request, "add.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -232,7 +232,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||
return redirect("list_sessions")
|
||||
context["title"] = "Edit Session"
|
||||
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:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# 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.0.0 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
|
@ -6,6 +6,7 @@ version = "3.8.1"
|
|||
description = "ASGI specs, helper code, and adapters"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"},
|
||||
{file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"},
|
||||
|
@ -20,6 +21,7 @@ version = "2024.8.30"
|
|||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
|
||||
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
|
||||
|
@ -31,6 +33,7 @@ version = "3.4.0"
|
|||
description = "Validate configuration and produce human readable error messages."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
|
||||
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
||||
|
@ -42,6 +45,7 @@ version = "3.4.0"
|
|||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
optional = false
|
||||
python-versions = ">=3.7.0"
|
||||
groups = ["main"]
|
||||
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_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
|
||||
|
@ -156,6 +160,7 @@ version = "8.1.7"
|
|||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
|
||||
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
|
||||
|
@ -170,10 +175,12 @@ version = "0.4.6"
|
|||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {main = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
|
@ -181,6 +188,7 @@ version = "5.0.1"
|
|||
description = "croniter provides iteration for datetime object with cron like format"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"},
|
||||
{file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"},
|
||||
|
@ -196,6 +204,7 @@ version = "1.15.1"
|
|||
description = "CSS unobfuscator and beautifier."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"},
|
||||
]
|
||||
|
@ -211,6 +220,7 @@ version = "0.3.9"
|
|||
description = "Distribution utilities"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
|
||||
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
|
||||
|
@ -218,13 +228,14 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.1.3"
|
||||
version = "5.1.5"
|
||||
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "Django-5.1.3-py3-none-any.whl", hash = "sha256:8b38a9a12da3ae00cb0ba72da985ec4b14de6345046b1e174b1fd7254398f818"},
|
||||
{file = "Django-5.1.3.tar.gz", hash = "sha256:c0fa0e619c39325a169208caef234f90baa925227032ad3f44842ba14d75234a"},
|
||||
{file = "Django-5.1.5-py3-none-any.whl", hash = "sha256:c46eb936111fffe6ec4bc9930035524a8be98ec2f74d8a0ff351226a3e52f459"},
|
||||
{file = "Django-5.1.5.tar.gz", hash = "sha256:19bbca786df50b9eca23cee79d495facf55c8f5c54c529d9bf1fe7b5ea086af3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
@ -242,6 +253,7 @@ version = "1.3.0"
|
|||
description = "Bringing component based design to Django templates."
|
||||
optional = false
|
||||
python-versions = "<4,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django_cotton-1.3.0-py3-none-any.whl", hash = "sha256:a23f29b759c43423e2f901352c0810388839cc412f6985614153c6ccfcfc2595"},
|
||||
{file = "django_cotton-1.3.0.tar.gz", hash = "sha256:8f4a15dd55c8ee9182cf7234c228ea45d9fcdec1de125221bce8d05af035730a"},
|
||||
|
@ -256,6 +268,7 @@ version = "4.4.6"
|
|||
description = "A configurable set of panels that display various debug information about the current request/response."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{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"},
|
||||
|
@ -271,6 +284,7 @@ version = "3.2.3"
|
|||
description = "Extensions for Django"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"},
|
||||
{file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"},
|
||||
|
@ -285,6 +299,7 @@ version = "1.21.0"
|
|||
description = "Extensions for using Django with htmx."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:64bc31463017a80552b767bc216ee5700248fa72e7ccd2963495e69afbdb6abe"},
|
||||
{file = "django_htmx-1.21.0.tar.gz", hash = "sha256:6ed3b42effd5980f22e68f36cd14ee4311bff3b6cb8435a89e27f45995691572"},
|
||||
|
@ -300,6 +315,7 @@ version = "3.2"
|
|||
description = "Pickled object field for Django"
|
||||
optional = false
|
||||
python-versions = ">=3"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django-picklefield-3.2.tar.gz", hash = "sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d"},
|
||||
{file = "django_picklefield-3.2-py3-none-any.whl", hash = "sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c"},
|
||||
|
@ -317,6 +333,7 @@ version = "1.7.4"
|
|||
description = "A multiprocessing distributed task queue for Django"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django_q2-1.7.4-py3-none-any.whl", hash = "sha256:6eda6d56505822ee5ebc6c4eac1dde726f5dbf20ee9ea7080575535852e2671f"},
|
||||
{file = "django_q2-1.7.4.tar.gz", hash = "sha256:56a3781cc480474fa9c04bbde62445b0a9b4195adc409bd963b8f593b0598c43"},
|
||||
|
@ -337,6 +354,7 @@ version = "24.4"
|
|||
description = "django-template-partials"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{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"},
|
||||
|
@ -355,6 +373,7 @@ version = "3.0.7"
|
|||
description = "Django/Jinja template indenter"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "djhtml-3.0.7.tar.gz", hash = "sha256:558c905b092a0c8afcbed27dea2f50aa6eb853a658b309e4e0f2bb378bdf6178"},
|
||||
]
|
||||
|
@ -368,6 +387,7 @@ version = "1.36.1"
|
|||
description = "HTML Template Linter and Formatter"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
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_11_0_arm64.whl", hash = "sha256:4712de3dea172000a098da6a0cd709d158909b4964ba0f68bee584cef18b4878"},
|
||||
|
@ -410,6 +430,7 @@ version = "0.12.4"
|
|||
description = "EditorConfig File Locator and Interpreter for Python"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"},
|
||||
]
|
||||
|
@ -420,6 +441,7 @@ version = "3.16.1"
|
|||
description = "A platform independent file lock."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
|
||||
{file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
|
||||
|
@ -436,6 +458,7 @@ version = "3.4.3"
|
|||
description = "GraphQL Framework for Python"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"},
|
||||
{file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"},
|
||||
|
@ -457,6 +480,7 @@ version = "3.2.2"
|
|||
description = "Graphene Django integration"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{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"},
|
||||
|
@ -481,6 +505,7 @@ version = "3.2.5"
|
|||
description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
|
||||
optional = false
|
||||
python-versions = "<4,>=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a"},
|
||||
{file = "graphql_core-3.2.5.tar.gz", hash = "sha256:e671b90ed653c808715645e3998b7ab67d382d55467b7e2978549111bbabf8d5"},
|
||||
|
@ -492,6 +517,7 @@ version = "3.2.0"
|
|||
description = "Relay library for graphql-core"
|
||||
optional = false
|
||||
python-versions = ">=3.6,<4"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"},
|
||||
{file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"},
|
||||
|
@ -506,6 +532,7 @@ version = "22.0.0"
|
|||
description = "WSGI HTTP Server for UNIX"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"},
|
||||
{file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"},
|
||||
|
@ -527,6 +554,7 @@ version = "0.14.0"
|
|||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
|
@ -538,6 +566,7 @@ version = "2.6.2"
|
|||
description = "File identification library for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"},
|
||||
{file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"},
|
||||
|
@ -552,6 +581,7 @@ version = "3.10"
|
|||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
|
@ -566,6 +596,7 @@ version = "2.0.0"
|
|||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
||||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||
|
@ -577,6 +608,7 @@ version = "5.13.2"
|
|||
description = "A Python utility / library to sort Python imports."
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
|
||||
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
|
||||
|
@ -591,6 +623,7 @@ version = "1.15.1"
|
|||
description = "JavaScript unobfuscator and beautifier."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"},
|
||||
]
|
||||
|
@ -605,6 +638,7 @@ version = "0.9.25"
|
|||
description = "A Python implementation of the JSON5 data format."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"},
|
||||
{file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"},
|
||||
|
@ -616,6 +650,7 @@ version = "3.7"
|
|||
description = "Python implementation of John Gruber's Markdown."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"},
|
||||
{file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"},
|
||||
|
@ -627,48 +662,49 @@ testing = ["coverage", "pyyaml"]
|
|||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.13.0"
|
||||
version = "1.15.0"
|
||||
description = "Optional static typing for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"},
|
||||
{file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"},
|
||||
{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.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"},
|
||||
{file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"},
|
||||
{file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"},
|
||||
{file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"},
|
||||
{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.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"},
|
||||
{file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"},
|
||||
{file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"},
|
||||
{file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"},
|
||||
{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.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"},
|
||||
{file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"},
|
||||
{file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"},
|
||||
{file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"},
|
||||
{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.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"},
|
||||
{file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"},
|
||||
{file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"},
|
||||
{file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"},
|
||||
{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.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"},
|
||||
{file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"},
|
||||
{file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"},
|
||||
{file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"},
|
||||
{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.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"},
|
||||
{file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"},
|
||||
{file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"},
|
||||
{file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"},
|
||||
{file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"},
|
||||
{file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"},
|
||||
{file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"},
|
||||
{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.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"},
|
||||
{file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"},
|
||||
{file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"},
|
||||
{file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"},
|
||||
{file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"},
|
||||
{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.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"},
|
||||
{file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"},
|
||||
{file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"},
|
||||
{file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"},
|
||||
{file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"},
|
||||
{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.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"},
|
||||
{file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"},
|
||||
{file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"},
|
||||
{file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"},
|
||||
{file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"},
|
||||
{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.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"},
|
||||
{file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"},
|
||||
{file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"},
|
||||
{file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"},
|
||||
{file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"},
|
||||
{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.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"},
|
||||
{file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"},
|
||||
{file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"},
|
||||
{file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
mypy-extensions = ">=1.0.0"
|
||||
typing-extensions = ">=4.6.0"
|
||||
mypy_extensions = ">=1.0.0"
|
||||
typing_extensions = ">=4.6.0"
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
|
@ -683,6 +719,7 @@ version = "1.0.0"
|
|||
description = "Type system extensions for programs checked with the mypy type checker."
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
|
||||
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
|
||||
|
@ -694,6 +731,7 @@ version = "1.9.1"
|
|||
description = "Node.js virtual environment builder"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
||||
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
||||
|
@ -705,6 +743,7 @@ version = "24.2"
|
|||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
|
||||
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
|
||||
|
@ -716,6 +755,7 @@ version = "0.12.1"
|
|||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
|
||||
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
|
||||
|
@ -727,6 +767,7 @@ version = "4.3.6"
|
|||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
|
||||
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
|
||||
|
@ -743,6 +784,7 @@ version = "1.5.0"
|
|||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
|
||||
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
|
||||
|
@ -758,6 +800,7 @@ version = "3.8.0"
|
|||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{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"},
|
||||
|
@ -776,6 +819,7 @@ version = "2.3"
|
|||
description = "Promises/A+ implementation for Python"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"},
|
||||
]
|
||||
|
@ -792,6 +836,7 @@ version = "8.3.3"
|
|||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
|
||||
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
|
||||
|
@ -812,6 +857,7 @@ version = "2.9.0.post0"
|
|||
description = "Extensions to the standard Python datetime module"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{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"},
|
||||
|
@ -826,6 +872,7 @@ version = "2024.2"
|
|||
description = "World timezone definitions, modern and historical"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
|
||||
{file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
|
||||
|
@ -837,6 +884,7 @@ version = "6.0.2"
|
|||
description = "YAML parser and emitter for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
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_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
|
||||
|
@ -899,6 +947,7 @@ version = "2024.11.6"
|
|||
description = "Alternative regular expression module, to replace re."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
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_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"},
|
||||
|
@ -1002,6 +1051,7 @@ version = "2.32.3"
|
|||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
|
||||
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
|
||||
|
@ -1023,6 +1073,7 @@ version = "1.16.0"
|
|||
description = "Python 2 and 3 compatibility utilities"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||
|
@ -1034,6 +1085,7 @@ version = "0.5.1"
|
|||
description = "A non-validating SQL parser."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"},
|
||||
{file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"},
|
||||
|
@ -1049,6 +1101,7 @@ version = "1.3"
|
|||
description = "The most basic Text::Unidecode port"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
|
||||
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
|
||||
|
@ -1060,6 +1113,7 @@ version = "4.67.0"
|
|||
description = "Fast, Extensible Progress Meter"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be"},
|
||||
{file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"},
|
||||
|
@ -1081,6 +1135,7 @@ version = "4.12.2"
|
|||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
|
@ -1092,6 +1147,8 @@ version = "2024.2"
|
|||
description = "Provider of IANA time zone data"
|
||||
optional = false
|
||||
python-versions = ">=2"
|
||||
groups = ["main", "dev"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
|
||||
{file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
|
||||
|
@ -1103,6 +1160,7 @@ version = "2.2.3"
|
|||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
|
||||
{file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
|
||||
|
@ -1120,6 +1178,7 @@ version = "0.30.6"
|
|||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"},
|
||||
{file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"},
|
||||
|
@ -1134,13 +1193,14 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
|
|||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.27.1"
|
||||
version = "20.29.1"
|
||||
description = "Virtual Python Environment builder"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"},
|
||||
{file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"},
|
||||
{file = "virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779"},
|
||||
{file = "virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
@ -1153,6 +1213,6 @@ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "s
|
|||
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)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "b5bb46a6591964aec145637cd9a412a681f2cc5e7e4fdd6fd9ecb0fe8724b8e3"
|
||||
|
|
|
@ -10,7 +10,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
|||
django.setup()
|
||||
from django.conf import settings
|
||||
|
||||
from games.models import Edition, Game, Platform, Purchase, Session
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
|
||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
@ -21,10 +21,8 @@ class PathWorksTest(TestCase):
|
|||
pl.save()
|
||||
g = Game(name="The Test Game")
|
||||
g.save()
|
||||
e = Edition(game=g, name="The Test Game Edition", platform=pl)
|
||||
e.save()
|
||||
p = Purchase(
|
||||
edition=e,
|
||||
games=[e],
|
||||
platform=pl,
|
||||
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||
)
|
||||
|
@ -53,11 +51,6 @@ class PathWorksTest(TestCase):
|
|||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_edition_returns_200(self):
|
||||
url = reverse("add_edition")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_purchase_returns_200(self):
|
||||
url = reverse("add_purchase")
|
||||
response = self.client.get(url)
|
||||
|
|
|
@ -3,14 +3,13 @@ from datetime import datetime
|
|||
from zoneinfo import ZoneInfo
|
||||
|
||||
import django
|
||||
from django.db import models
|
||||
from django.test import TestCase
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
|
||||
from games.models import Edition, Game, Purchase, Session
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
@ -22,10 +21,8 @@ class FormatDurationTest(TestCase):
|
|||
def test_duration_format(self):
|
||||
g = Game(name="The Test Game")
|
||||
g.save()
|
||||
e = Edition(game=g, name="The Test Game Edition")
|
||||
e.save()
|
||||
p = Purchase(
|
||||
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
|
||||
game=g, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
|
||||
)
|
||||
p.save()
|
||||
s = Session(
|
||||
|
|
|
@ -47,7 +47,7 @@ INSTALLED_APPS = [
|
|||
|
||||
Q_CLUSTER = {
|
||||
"name": "DjangoQ",
|
||||
"workers": 4,
|
||||
"workers": 1,
|
||||
"recycle": 500,
|
||||
"timeout": 60,
|
||||
"retry": 120,
|
||||
|
@ -113,6 +113,10 @@ DATABASES = {
|
|||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
"OPTIONS": {
|
||||
"timeout": 20,
|
||||
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue