1 Commits

Author SHA1 Message Date
527cf4ba52 Make it possible to convert prices automatically
Some checks failed
Django CI/CD / test (push) Failing after 1m19s
Django CI/CD / build-and-push (push) Has been skipped
2024-11-10 15:34:08 +01:00
34 changed files with 281 additions and 901 deletions

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

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

View File

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

View File

@ -3,13 +3,11 @@ from string import ascii_lowercase
from typing import Any, Callable from typing import Any, Callable
from django.template import TemplateDoesNotExist from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate from common.utils import truncate
from games.models import Purchase
HTMLAttribute = tuple[str, str | int | bool] HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str HTMLTag = str
@ -52,7 +50,6 @@ def randomid(seed: str = "", length: int = 10) -> str:
def Popover( def Popover(
popover_content: str, popover_content: str,
wrapped_content: str = "", wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [], children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] = [],
) -> str: ) -> str:
@ -65,43 +62,17 @@ def Popover(
("id", id), ("id", id),
("wrapped_content", wrapped_content), ("wrapped_content", wrapped_content),
("popover_content", popover_content), ("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
], ],
children=children, children=children,
template="cotton/popover.html", template="cotton/popover.html",
) )
def PopoverTruncated( def PopoverTruncated(input_string: str) -> str:
input_string: str, if (truncated := truncate(input_string)) != input_string:
popover_content: str = "", return Popover(wrapped_content=truncated, popover_content=input_string)
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: else:
if popover_content and popover_if_not_truncated: return input_string
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
else:
return input_string
def A( def A(
@ -209,47 +180,6 @@ def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeTe
) )
def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
edition_count = purchase.editions.count()
popover_if_not_truncated = False
if edition_count == 1:
link_content += purchase.editions.first().name
popover_content = link_content
if edition_count > 1:
if purchase.name:
link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{edition_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{edition.name}</li>" for edition in purchase.editions.all())}
</ul>
"""
icon = purchase.platform.icon if edition_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 NameWithPlatformIcon(name: str, platform: str) -> SafeText: def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
content = Div( content = Div(
[("class", "inline-flex gap-2 items-center")], [("class", "inline-flex gap-2 items-center")],
@ -263,11 +193,3 @@ def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
) )
return mark_safe(content) return mark_safe(content)
def PurchasePrice(purchase) -> str:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
)

View File

@ -34,31 +34,14 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
return obj 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 ( return (
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}") (f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > length if len(input_string) > 30
else input_string 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) T = TypeVar("T", str, int, date)

View File

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

View File

@ -1,14 +1,6 @@
from django.contrib import admin from django.contrib import admin
from games.models import ( from games.models import Device, Edition, Game, Platform, Purchase, Session
Device,
Edition,
ExchangeRate,
Game,
Platform,
Purchase,
Session,
)
# Register your models here. # Register your models here.
admin.site.register(Game) admin.site.register(Game)
@ -17,4 +9,3 @@ admin.site.register(Platform)
admin.site.register(Session) admin.site.register(Session)
admin.site.register(Edition) admin.site.register(Edition)
admin.site.register(Device) admin.site.register(Device)
admin.site.register(ExchangeRate)

View File

@ -1,9 +1,9 @@
from datetime import timedelta from datetime import timedelta
from django.apps import AppConfig from django.apps import AppConfig
from django.core.management import call_command
from django.db.models.signals import post_migrate
from django.utils.timezone import now from django.utils.timezone import now
from django_q.models import Schedule
from django_q.tasks import schedule
class GamesConfig(AppConfig): class GamesConfig(AppConfig):
@ -11,23 +11,10 @@ class GamesConfig(AppConfig):
name = "games" name = "games"
def ready(self): def ready(self):
post_migrate.connect(schedule_tasks, sender=self) if not Schedule.objects.filter(name="Update converted prices").exists():
schedule(
"games.tasks.update_converted_prices",
def schedule_tasks(sender, **kwargs): name="Update converted prices",
from django_q.models import Schedule schedule_type="D",
from django_q.tasks import schedule next_run=now() + timedelta(days=1),
)
if not Schedule.objects.filter(name="Update converted prices").exists():
schedule(
"games.tasks.convert_prices",
name="Update converted prices",
schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30),
)
from games.models import ExchangeRate
if not ExchangeRate.objects.exists():
print("ExchangeRate table is empty. Loading fixture...")
call_command("loaddata", "exchangerates.yaml")

View File

@ -2,31 +2,20 @@ import requests
from games.models import ExchangeRate, Purchase from games.models import ExchangeRate, Purchase
# fixme: save preferred currency in user model
currency_to = "CZK"
currency_to = currency_to.upper()
def update_converted_prices():
def save_converted_info(purchase, converted_price, converted_currency):
print(
f"Changing converted price of {purchase} to {converted_price} {converted_currency} "
)
purchase.converted_price = converted_price
purchase.converted_currency = converted_currency
purchase.save()
def convert_prices():
purchases = Purchase.objects.filter( purchases = Purchase.objects.filter(
converted_price__isnull=True, converted_currency__isnull=True converted_price__isnull=True, converted_currency__isnull=True
) )
for purchase in purchases: for purchase in purchases:
if purchase.price_currency.upper() == currency_to or purchase.price == 0: if purchase.price == 0:
save_converted_info(purchase, purchase.price, currency_to)
continue continue
year = purchase.date_purchased.year year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper() currency_from = purchase.price_currency
# fixme: save preferred currency in user model
currency_to = "CZK"
exchange_rate = ExchangeRate.objects.filter( exchange_rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year currency_from=currency_from, currency_to=currency_to, year=year
).first() ).first()
@ -34,11 +23,12 @@ def convert_prices():
if not exchange_rate: if not exchange_rate:
try: try:
response = requests.get( response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from}.json" f"https://api.exchangeratesapi.io/{year}-01-01",
params={"base": currency_to, "symbols": "currency_from"},
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
rate = data[currency_from].get(currency_to) rate = data["rates"].get(currency_from)
if rate: if rate:
exchange_rate = ExchangeRate.objects.create( exchange_rate = ExchangeRate.objects.create(
@ -49,9 +39,10 @@ def convert_prices():
) )
except requests.RequestException as e: except requests.RequestException as e:
print( print(
f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" f"Failed to fetch exchange rate for {currency_from} in {year}: {e}"
) )
if exchange_rate: if exchange_rate:
save_converted_info( converted_price = purchase.price * exchange_rate.rate
purchase, purchase.price * exchange_rate.rate, currency_to purchase.converted_price = converted_price
) purchase.converted_currency = currency_to
purchase.save()

View File

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

View File

@ -16,7 +16,7 @@ class SessionForm(forms.ModelForm):
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name") # queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# ) # )
purchase = forms.ModelChoiceField( purchase = forms.ModelChoiceField(
queryset=Purchase.objects.all(), queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}), widget=forms.Select(attrs={"autofocus": "autofocus"}),
) )
@ -38,12 +38,12 @@ class SessionForm(forms.ModelForm):
] ]
class EditionChoiceField(forms.ModelMultipleChoiceField): class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str: def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.SelectMultiple): class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs) option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"): if platform_id := safe_getattr(value, "instance.platform.id"):
@ -58,7 +58,7 @@ class PurchaseForm(forms.ModelForm):
# Automatically update related_purchase <select/> # Automatically update related_purchase <select/>
# to only include purchases of the selected edition. # to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition") related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["editions"].widget.attrs.update( self.fields["edition"].widget.attrs.update(
{ {
"hx-trigger": "load, click", "hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url, "hx-get": related_purchase_by_edition_url,
@ -67,13 +67,15 @@ class PurchaseForm(forms.ModelForm):
} }
) )
editions = EditionChoiceField( edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField( related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME), queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False, required=False,
) )
@ -86,7 +88,7 @@ class PurchaseForm(forms.ModelForm):
} }
model = Purchase model = Purchase
fields = [ fields = [
"editions", "edition",
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",

View File

@ -12,10 +12,10 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
if not Schedule.objects.filter(name="Update converted prices").exists(): if not Schedule.objects.filter(name="Update converted prices").exists():
schedule( schedule(
"games.tasks.convert_prices", "games.tasks.update_converted_prices",
name="Update converted prices", name="Update converted prices",
schedule_type=Schedule.MINUTES, schedule_type="D",
next_run=now() + timedelta(seconds=30), next_run=now() + timedelta(days=1),
) )
self.stdout.write( self.stdout.write(
self.style.SUCCESS("Scheduled the update_converted_prices task.") self.style.SUCCESS("Scheduled the update_converted_prices task.")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-08 20:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0045_alter_purchase_editions"),
]
operations = [
migrations.AddField(
model_name="game",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.platform",
),
),
migrations.AlterUniqueTogether(
name="game",
unique_together={("name", "platform", "year_released")},
),
]

View File

@ -1,61 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-19 20:29
from django.db import connection, migrations, models
def recreate_games(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
Game = apps.get_model("games", "Game")
Purchase = apps.get_model("games", "Purchase")
with connection.cursor() as cursor:
print("Create table games_gametemp")
cursor.execute(
"CREATE TABLE games_gametemp AS SELECT * FROM games_game WHERE 1=0;"
)
for edition in Edition.objects.all():
print(f"Re-create edition with ID {edition.id}")
cursor.execute(
"""
INSERT INTO games_gametemp (
id, name, sort_name, year_released, platform_id, wikidata, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
[
edition.id, # Reuse the Edition ID
edition.name,
edition.sort_name,
edition.year_released,
edition.platform_id,
Game.objects.get(id=edition.game.id).wikidata,
edition.created_at,
],
)
print("Turn foreign keys off")
cursor.execute("PRAGMA foreign_keys = OFF;")
print("Drop table games_game")
cursor.execute("DROP TABLE games_game;")
print("Drop table games_edition")
cursor.execute("DROP TABLE games_edition;")
print("Rename table games_gametemp to games_game")
# cursor.execute("ALTER TABLE games_gametemp RENAME TO games_game;")
cursor.execute("CREATE TABLE games_game AS SELECT * FROM games_gametemp;")
class Migration(migrations.Migration):
dependencies = [
("games", "0046_game_platform_alter_game_unique_together"),
]
operations = [
migrations.RunPython(recreate_games),
migrations.AlterField(
model_name="purchase",
name="editions",
field=models.ManyToManyField(
blank=True, related_name="purchases", to="games.game"
),
),
]

View File

@ -9,6 +9,20 @@ from django.utils import timezone
from common.time import format_duration from common.time import format_duration
class Game(models.Model):
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)
created_at = models.DateTimeField(auto_now_add=True)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255, null=True, blank=True, default=None)
@ -30,26 +44,6 @@ def get_sentinel_platform():
)[0] )[0]
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)
platform = models.ForeignKey(
Platform, on_delete=models.SET_DEFAULT, 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)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
class Edition(models.Model): class Edition(models.Model):
class Meta: class Meta:
unique_together = [["name", "platform", "year_released"]] unique_together = [["name", "platform", "year_released"]]
@ -119,7 +113,7 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager() objects = PurchaseQueryset().as_manager()
editions = models.ManyToManyField(Game, related_name="purchases", blank=True) edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
platform = models.ForeignKey( platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
) )
@ -130,8 +124,8 @@ class Purchase(models.Model):
infinite = models.BooleanField(default=False) infinite = models.BooleanField(default=False)
price = models.FloatField(default=0) price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True) converted_price = models.FloatField()
converted_currency = models.CharField(max_length=3, null=True) converted_currency = models.CharField(max_length=3)
ownership_type = models.CharField( ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
@ -147,41 +141,29 @@ class Purchase(models.Model):
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@property
def first_edition(self):
return self.editions.first()
def __str__(self): def __str__(self):
additional_info = [ additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "", self.get_type_display() if self.type != Purchase.GAME else "",
( (
f"{self.first_edition.platform} version on {self.platform}" f"{self.edition.platform} version on {self.platform}"
if self.platform != self.first_edition.platform if self.platform != self.edition.platform
else self.platform else self.platform
), ),
self.first_edition.year_released, self.edition.year_released,
self.get_ownership_type_display(), self.get_ownership_type_display(),
] ]
return f"{self.first_edition} ({', '.join(filter(None, map(str, additional_info)))})" return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase: if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError( raise ValidationError(
f"{self.get_type_display()} must have a related purchase." f"{self.get_type_display()} must have a related purchase."
) )
if self.pk is not None:
# Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields
if (
existing_purchase.price != self.price
or existing_purchase.price_currency != self.price_currency
):
self.converted_price = None
self.converted_currency = None
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@ -1652,14 +1652,6 @@ input:checked + .toggle-bg {
resize: both; resize: both;
} }
.list-inside {
list-style-position: inside;
}
.list-disc {
list-style-type: disc;
}
.grid-cols-4 { .grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@ -2200,10 +2192,6 @@ input:checked + .toggle-bg {
text-decoration-color: #64748b; text-decoration-color: #64748b;
} }
.decoration-dotted {
text-decoration-style: dotted;
}
.opacity-0 { .opacity-0 {
opacity: 0; opacity: 0;
} }
@ -3129,10 +3117,6 @@ textarea:disabled:is(.dark *) {
flex-direction: row; flex-direction: row;
} }
.md\:justify-between {
justify-content: space-between;
}
.md\:space-x-8 > :not([hidden]) ~ :not([hidden]) { .md\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
margin-right: calc(2rem * var(--tw-space-x-reverse)); margin-right: calc(2rem * var(--tw-space-x-reverse));
@ -3276,13 +3260,3 @@ textarea:disabled:is(.dark *) {
.\[\&_td\:last-child\]\:text-right td:last-child { .\[\&_td\:last-child\]\:text-right td:last-child {
text-align: right; text-align: right;
} }
@media not all and (min-width: 640px) {
.\[\&_td\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden td:not(:first-child):not(:last-child) {
display: none;
}
.\[\&_th\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden th:not(:first-child):not(:last-child) {
display: none;
}
}

View File

@ -1,10 +1,8 @@
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span> <span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
<div data-popover <div data-popover
id="{{ id }}" id="{{ id }}"
role="tooltip" 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"> 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 class="px-3 py-2">{{ popover_content }}</div>
<div data-popper-arrow></div> <div data-popper-arrow></div>
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
<span class="hidden decoration-dotted"></span>
</div> </div>

View File

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

View File

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

View File

@ -3,7 +3,7 @@
<div id="game-info" class="mb-10"> <div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3"> <div class="flex gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl"> <span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %} <span class="font-bold font-serif">{{ game.name }}</span>&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>
</span> </span>
</div> </div>
<div class="flex gap-4 dark:text-slate-400 mb-3"> <div class="flex gap-4 dark:text-slate-400 mb-3">

View File

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

View File

@ -54,11 +54,6 @@ urlpatterns = [
purchase.delete_purchase, purchase.delete_purchase,
name="delete_purchase", name="delete_purchase",
), ),
path(
"purchase/<int:purchase_id>/view",
purchase.view_purchase,
name="view_purchase",
),
path( path(
"purchase/<int:purchase_id>/finish", "purchase/<int:purchase_id>/finish",
purchase.finish_purchase, purchase.finish_purchase,

View File

@ -13,11 +13,9 @@ from common.components import (
Button, Button,
Div, Div,
Icon, Icon,
LinkedPurchase,
NameWithPlatformIcon, NameWithPlatformIcon,
Popover, Popover,
PopoverTruncated, PopoverTruncated,
PurchasePrice,
) )
from common.time import ( from common.time import (
dateformat, dateformat,
@ -27,7 +25,7 @@ from common.time import (
local_strftime, local_strftime,
timeformat, timeformat,
) )
from common.utils import safe_division, truncate from common.utils import format_float_or_int, safe_division, truncate
from games.forms import GameForm from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session from games.models import Edition, Game, Purchase, Session
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@ -163,7 +161,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
to_attr="nongame_related_purchases", to_attr="nongame_related_purchases",
) )
game_purchases_prefetch: Prefetch[Purchase] = Prefetch( game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
"purchases", "purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related( queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch nongame_related_purchases_prefetch
), ),
@ -175,14 +173,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
.order_by("year_released") .order_by("year_released")
) )
purchases = Purchase.objects.filter(editions__game=game).order_by("date_purchased") purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased")
sessions = Session.objects.prefetch_related("device").filter( sessions = Session.objects.prefetch_related("device").filter(
purchase__editions__game=game purchase__edition__game=game
) )
session_count = sessions.count() session_count = sessions.count()
session_count_without_manual = ( session_count_without_manual = (
Session.objects.without_manual().filter(purchase__editions__game=game).count() Session.objects.without_manual().filter(purchase__edition__game=game).count()
) )
if sessions: if sessions:
@ -243,10 +241,13 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"columns": ["Name", "Type", "Date", "Price", "Actions"], "columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [ "rows": [
[ [
LinkedPurchase(purchase), NameWithPlatformIcon(
name=purchase.name if purchase.name else purchase.edition.name,
platform=purchase.platform,
),
purchase.get_type_display(), purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat), purchase.date_purchased.strftime(dateformat),
PurchasePrice(purchase), f"{format_float_or_int(purchase.price)} {purchase.price_currency}",
render_to_string( render_to_string(
"cotton/button_group.html", "cotton/button_group.html",
{ {
@ -269,7 +270,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
], ],
} }
sessions_all = Session.objects.filter(purchase__editions__game=game).order_by( sessions_all = Session.objects.filter(purchase__edition__game=game).order_by(
"-timestamp_start" "-timestamp_start"
) )
last_session = None last_session = None
@ -298,7 +299,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
args=[last_session.pk], args=[last_session.pk],
), ),
children=Popover( children=Popover(
popover_content=last_session.purchase.first_edition.name, popover_content=last_session.purchase.edition.name,
children=[ children=[
Button( Button(
icon=True, icon=True,
@ -306,9 +307,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
size="xs", size="xs",
children=[ children=[
Icon("play"), Icon("play"),
truncate( truncate(f"{last_session.purchase.edition.name}"),
f"{last_session.purchase.first_edition.name}"
),
], ],
) )
], ],
@ -324,7 +323,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
NameWithPlatformIcon( NameWithPlatformIcon(
name=session.purchase.name name=session.purchase.name
if session.purchase.name if session.purchase.name
else session.purchase.first_edition.name, else session.purchase.edition.name,
platform=session.purchase.platform, platform=session.purchase.platform,
), ),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
@ -375,7 +374,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"editions": editions, "editions": editions,
"game": game, "game": game,
"playrange": playrange, "playrange": playrange,
"purchase_count": Purchase.objects.filter(editions__game=game).count(), "purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average_without_manual": round( "session_average_without_manual": round(
safe_division( safe_division(
total_hours_without_manual, int(session_count_without_manual) total_hours_without_manual, int(session_count_without_manual)

View File

@ -2,7 +2,7 @@ from datetime import datetime
from typing import Any, Callable from typing import Any, Callable
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields
from django.db.models.functions import TruncDate, TruncMonth from django.db.models.functions import TruncDate, TruncMonth
from django.db.models.manager import BaseManager from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
@ -49,9 +49,7 @@ def use_custom_redirect(
@login_required @login_required
def stats_alltime(request: HttpRequest) -> HttpResponse: def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime" year = "Alltime"
this_year_sessions = Session.objects.all().prefetch_related( this_year_sessions = Session.objects.all().select_related("purchase__edition")
Prefetch("purchase__editions")
)
this_year_sessions_with_durations = this_year_sessions.annotate( this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper( duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), F("timestamp_end") - F("timestamp_start"),
@ -60,10 +58,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
) )
longest_session = this_year_sessions_with_durations.order_by("-duration").first() longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter( this_year_games = Game.objects.filter(
editions__purchase__session__in=this_year_sessions edition__purchase__session__in=this_year_sessions
).distinct() ).distinct()
this_year_games_with_session_counts = this_year_games.annotate( this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("editions__purchase__session"), session_count=Count("edition__purchase__session"),
) )
game_highest_session_count = this_year_games_with_session_counts.order_by( game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count" "-session_count"
@ -80,7 +78,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
).distinct() ).distinct()
this_year_purchases = Purchase.objects.all() this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("editions") this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None date_refunded=None
) )
@ -124,16 +124,16 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
).order_by("date_finished") ).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price")) total_spent=Sum(F("price"))
) )
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions) Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("editions__purchase__session__duration_calculated") F("edition__purchase__session__duration_calculated")
+ F("editions__purchase__session__duration_manual") + F("edition__purchase__session__duration_manual")
) )
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
@ -148,9 +148,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H") month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions) Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate( .annotate(
session_average=Avg("editions__purchase__session__duration_calculated") session_average=Avg("edition__purchase__session__duration_calculated")
) )
.order_by("-session_average") .order_by("-session_average")
.first() .first()
@ -177,10 +177,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
last_play_date = "N/A" last_play_date = "N/A"
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.first_edition.game first_play_game = first_session.purchase.edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat) first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest() last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game last_play_game = last_session.purchase.edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -229,7 +229,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
else 0 else 0
), ),
"longest_session_game": ( "longest_session_game": (
longest_session.purchase.first_edition.game if longest_session else None longest_session.purchase.edition.game if longest_session else None
), ),
"highest_session_count": ( "highest_session_count": (
game_highest_session_count.session_count game_highest_session_count.session_count
@ -268,7 +268,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("stats_alltime")) return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter( this_year_sessions = Session.objects.filter(
timestamp_start__year=year timestamp_start__year=year
).prefetch_related("purchase__editions") ).select_related("purchase__edition")
this_year_sessions_with_durations = this_year_sessions.annotate( this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper( duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), F("timestamp_end") - F("timestamp_start"),
@ -277,12 +277,12 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
) )
longest_session = this_year_sessions_with_durations.order_by("-duration").first() longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter( this_year_games = Game.objects.filter(
edition__purchases__session__in=this_year_sessions edition__purchase__session__in=this_year_sessions
).distinct() ).distinct()
this_year_games_with_session_counts = this_year_games.annotate( this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count( session_count=Count(
"edition__purchases__session", "edition__purchase__session",
filter=Q(edition__purchases__session__timestamp_start__year=year), filter=Q(edition__purchase__session__timestamp_start__year=year),
) )
) )
game_highest_session_count = this_year_games_with_session_counts.order_by( game_highest_session_count = this_year_games_with_session_counts.order_by(
@ -300,10 +300,12 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
).distinct() ).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year) this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.prefetch_related("editions") this_year_purchases_with_currency = this_year_purchases.select_related("edition")
# fixme: add conversion table for currencies
# ).filter(price_currency__exact=selected_currency)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None date_refunded=None
).exclude(ownership_type=Purchase.DEMO) )
this_year_purchases_refunded = this_year_purchases_with_currency.refunded() this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished_dropped_nondropped = (
@ -337,7 +339,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = ( purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(editions__year_released=year).order_by( purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished" "date_finished"
) )
) )
@ -346,16 +348,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
).order_by("date_finished") ).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price")) total_spent=Sum(F("price"))
) )
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(edition__purchases__session__in=this_year_sessions) Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("edition__purchases__session__duration_calculated") F("edition__purchase__session__duration_calculated")
+ F("edition__purchases__session__duration_manual") + F("edition__purchase__session__duration_manual")
) )
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
@ -370,9 +372,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H") month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(edition__purchases__session__in=this_year_sessions) Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate( .annotate(
session_average=Avg("edition__purchases__session__duration_calculated") session_average=Avg("edition__purchase__session__duration_calculated")
) )
.order_by("-session_average") .order_by("-session_average")
.first() .first()
@ -403,10 +405,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = None last_play_game = None
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.first_edition.game first_play_game = first_session.purchase.edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat) first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest() last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game last_play_game = last_session.purchase.edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -423,7 +425,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"total_games": this_year_played_purchases.count(), "total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter( "total_2023_games": this_year_played_purchases.filter(
editions__year_released=year edition__year_released=year
).count(), ).count(),
"top_10_games_by_playtime": top_10_games_by_playtime, "top_10_games_by_playtime": top_10_games_by_playtime,
"year": year, "year": year,
@ -434,16 +436,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"spent_per_game": int( "spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count) safe_division(total_spent, this_year_purchases_without_refunded_count)
), ),
"all_finished_this_year": purchases_finished_this_year.prefetch_related( "all_finished_this_year": purchases_finished_this_year.select_related(
"editions" "edition"
).order_by("date_finished"), ).order_by("date_finished"),
"all_finished_this_year_count": purchases_finished_this_year.count(), "all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related( "this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
"editions" "edition"
).order_by("date_finished"), ).order_by("date_finished"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related(
"editions" "edition"
).order_by("date_finished"), ).order_by("date_finished"),
"total_sessions": this_year_sessions.count(), "total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"], "unique_days": unique_days["dates"],
@ -473,7 +475,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
else 0 else 0
), ),
"longest_session_game": ( "longest_session_game": (
longest_session.purchase.first_edition.game if longest_session else None longest_session.purchase.edition.game if longest_session else None
), ),
"highest_session_count": ( "highest_session_count": (
game_highest_session_count.session_count game_highest_session_count.session_count

View File

@ -13,8 +13,9 @@ from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice from common.components import A, Button, Icon, LinkedNameWithPlatformIcon
from common.time import dateformat from common.time import dateformat
from common.utils import format_float_or_int
from games.forms import PurchaseForm from games.forms import PurchaseForm
from games.models import Edition, Purchase from games.models import Edition, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
@ -48,6 +49,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
"Name", "Name",
"Type", "Type",
"Price", "Price",
"Currency",
"Infinite", "Infinite",
"Purchased", "Purchased",
"Refunded", "Refunded",
@ -58,9 +60,14 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
], ],
"rows": [ "rows": [
[ [
LinkedPurchase(purchase), LinkedNameWithPlatformIcon(
name=purchase.edition.name,
game_id=purchase.edition.game.pk,
platform=purchase.platform,
),
purchase.get_type_display(), purchase.get_type_display(),
PurchasePrice(purchase), format_float_or_int(purchase.price),
purchase.price_currency,
purchase.infinite, purchase.infinite,
purchase.date_purchased.strftime(dateformat), purchase.date_purchased.strftime(dateformat),
( (
@ -169,7 +176,7 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
# context["script_name"] = "add_purchase.js" context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@ -185,7 +192,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
context["purchase_id"] = str(purchase_id) 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) return render(request, "add_purchase.html", context)
@ -196,12 +203,6 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("list_purchases") return redirect("list_purchases")
@login_required
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
return render(request, "view_purchase.html", {"purchase": purchase})
@login_required @login_required
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)

View File

@ -47,10 +47,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
| Q(device__name__icontains=search_string) | Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string) | Q(device__type__icontains=search_string)
) )
try: last_session = sessions.latest()
last_session = sessions.latest()
except Session.DoesNotExist:
last_session = None
page_obj = None page_obj = None
if int(limit) != 0: if int(limit) != 0:
paginator = Paginator(sessions, limit) paginator = Paginator(sessions, limit)
@ -97,7 +94,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
args=[last_session.pk], args=[last_session.pk],
), ),
children=Popover( children=Popover(
popover_content=last_session.purchase.first_edition.name, popover_content=last_session.purchase.edition.name,
children=[ children=[
Button( Button(
icon=True, icon=True,
@ -106,15 +103,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=[ children=[
Icon("play"), Icon("play"),
truncate( truncate(
f"{last_session.purchase.first_edition.name}" f"{last_session.purchase.edition.name}"
), ),
], ],
) )
], ],
), ),
) ),
if last_session
else "",
] ]
), ),
], ],
@ -131,8 +126,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
"rows": [ "rows": [
[ [
LinkedNameWithPlatformIcon( LinkedNameWithPlatformIcon(
name=session.purchase.first_edition.name, name=session.purchase.edition.name,
game_id=session.purchase.first_edition.game.pk, game_id=session.purchase.edition.game.pk,
platform=session.purchase.platform, platform=session.purchase.platform,
), ),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",

View File

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

310
poetry.lock generated
View File

@ -1,5 +1,35 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "ansicon"
version = "1.89.0"
description = "Python wrapper for loading Jason Hood's ANSICON"
optional = false
python-versions = "*"
files = [
{file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"},
{file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"},
]
[[package]]
name = "arrow"
version = "1.3.0"
description = "Better dates & times for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"},
{file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"},
]
[package.dependencies]
python-dateutil = ">=2.7.0"
types-python-dateutil = ">=2.8.10"
[package.extras]
doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"]
test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"]
[[package]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.8.1" version = "3.8.1"
@ -15,16 +45,21 @@ files = [
tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
[[package]] [[package]]
name = "certifi" name = "blessed"
version = "2024.8.30" version = "1.20.0"
description = "Python package for providing Mozilla's CA Bundle." description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=2.7"
files = [ files = [
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, {file = "blessed-1.20.0-py2.py3-none-any.whl", hash = "sha256:0c542922586a265e699188e52d5f5ac5ec0dd517e5a1041d90d2bbf23f906058"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, {file = "blessed-1.20.0.tar.gz", hash = "sha256:2cdd67f8746e048f00df47a2880f4d6acbcdb399031b604e34ba8f71d5787680"},
] ]
[package.dependencies]
jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""}
six = ">=1.9.0"
wcwidth = ">=0.1.4"
[[package]] [[package]]
name = "cfgv" name = "cfgv"
version = "3.4.0" version = "3.4.0"
@ -36,120 +71,6 @@ files = [
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
] ]
[[package]]
name = "charset-normalizer"
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"
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"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"},
{file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"},
{file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"},
{file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"},
{file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"},
{file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"},
{file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"},
{file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"},
{file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"},
{file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"},
{file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"},
{file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"},
{file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"},
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.7" version = "8.1.7"
@ -175,21 +96,6 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "croniter"
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"
files = [
{file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"},
{file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"},
]
[package.dependencies]
python-dateutil = "*"
pytz = ">2021.1"
[[package]] [[package]]
name = "cssbeautifier" name = "cssbeautifier"
version = "1.15.1" version = "1.15.1"
@ -312,24 +218,27 @@ Django = ">=3.2"
tests = ["tox"] tests = ["tox"]
[[package]] [[package]]
name = "django-q2" name = "django-q"
version = "1.7.4" version = "1.3.9"
description = "A multiprocessing distributed task queue for Django" description = "A multiprocessing distributed task queue for Django"
optional = false optional = false
python-versions = "<4,>=3.8" python-versions = ">=3.6.2,<4"
files = [ files = [
{file = "django_q2-1.7.4-py3-none-any.whl", hash = "sha256:6eda6d56505822ee5ebc6c4eac1dde726f5dbf20ee9ea7080575535852e2671f"}, {file = "django-q-1.3.9.tar.gz", hash = "sha256:5c6b4d530aa3aabf9c6aa57376da1ca2abf89a1562b77038b7a04e52a4a0a91b"},
{file = "django_q2-1.7.4.tar.gz", hash = "sha256:56a3781cc480474fa9c04bbde62445b0a9b4195adc409bd963b8f593b0598c43"}, {file = "django_q-1.3.9-py3-none-any.whl", hash = "sha256:1b74ce3a8931990b136903e3a7bc9b07243282a2b5355117246f05ed5d076e68"},
] ]
[package.dependencies] [package.dependencies]
django = ">=4.2,<6" arrow = ">=1.1.0,<2.0.0"
django-picklefield = ">=3.1,<4.0" blessed = ">=1.17.6,<2.0.0"
django = ">=2.2"
django-picklefield = ">=3.0.1,<4.0.0"
redis = ">=3.5.3,<4.0.0"
[package.extras] [package.extras]
rollbar = ["django-q-rollbar (>=0.1)"] rollbar = ["django-q-rollbar (>=0.1)"]
sentry = ["django-q-sentry (>=0.1)"] sentry = ["django-q-sentry (>=0.1)"]
testing = ["blessed (>=1.19.1,<2.0.0)", "boto3 (>=1.24.92,<2.0.0)", "croniter (>=2.0.1,<3.0.0)", "django-redis (>=5.2.0,<6.0.0)", "hiredis (>=2.0.0,<3.0.0)", "iron-mq (>=0.9,<0.10)", "psutil (>=5.9.2,<6.0.0)", "pymongo (>=4.2.0,<5.0.0)", "redis (>=4.3.4,<5.0.0)", "setproctitle (>=1.3.2,<2.0.0)"] testing = ["boto3 (>=1.14.12,<2.0.0)", "croniter (>=0.3.34,<0.4.0)", "django-redis (>=4.12.1,<5.0.0)", "hiredis (>=1.0.1,<2.0.0)", "iron-mq (>=0.9,<0.10)", "psutil (>=5.7.0,<6.0.0)", "pymongo (>=3.10.1,<4.0.0)"]
[[package]] [[package]]
name = "django-template-partials" name = "django-template-partials"
@ -546,20 +455,6 @@ files = [
[package.extras] [package.extras]
license = ["ukkonen"] license = ["ukkonen"]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.0.0" version = "2.0.0"
@ -585,6 +480,20 @@ files = [
[package.extras] [package.extras]
colors = ["colorama (>=0.4.6)"] colors = ["colorama (>=0.4.6)"]
[[package]]
name = "jinxed"
version = "1.3.0"
description = "Jinxed Terminal Library"
optional = false
python-versions = "*"
files = [
{file = "jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5"},
{file = "jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf"},
]
[package.dependencies]
ansicon = {version = "*", markers = "platform_system == \"Windows\""}
[[package]] [[package]]
name = "jsbeautifier" name = "jsbeautifier"
version = "1.15.1" version = "1.15.1"
@ -820,17 +729,6 @@ files = [
[package.dependencies] [package.dependencies]
six = ">=1.5" six = ">=1.5"
[[package]]
name = "pytz"
version = "2024.2"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
files = [
{file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
{file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.2" version = "6.0.2"
@ -893,6 +791,20 @@ files = [
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
] ]
[[package]]
name = "redis"
version = "3.5.3"
description = "Python client for Redis key-value store"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
]
[package.extras]
hiredis = ["hiredis (>=0.1.3)"]
[[package]] [[package]]
name = "regex" name = "regex"
version = "2024.11.6" version = "2024.11.6"
@ -996,27 +908,6 @@ files = [
{file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"},
] ]
[[package]]
name = "requests"
version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
@ -1075,6 +966,17 @@ notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"] slack = ["slack-sdk"]
telegram = ["requests"] telegram = ["requests"]
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20241003"
description = "Typing stubs for python-dateutil"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"},
{file = "types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d"},
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@ -1097,23 +999,6 @@ files = [
{file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
] ]
[[package]]
name = "urllib3"
version = "2.2.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.8"
files = [
{file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
{file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.30.6" version = "0.30.6"
@ -1152,7 +1037,18 @@ platformdirs = ">=3.9.1,<5"
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
[[package]]
name = "wcwidth"
version = "0.2.13"
description = "Measures the displayed width of unicode strings in a terminal"
optional = false
python-versions = "*"
files = [
{file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "b5bb46a6591964aec145637cd9a412a681f2cc5e7e4fdd6fd9ecb0fe8724b8e3" content-hash = "73eb962c5e82eb678d42049c01bbf01772aec6bfabc5e710e2364444c7684b2c"

View File

@ -29,11 +29,8 @@ django-htmx = "^1.18.0"
django-template-partials = "^24.2" django-template-partials = "^24.2"
markdown = "^3.6" markdown = "^3.6"
django-cotton = "^1.2.1" django-cotton = "^1.2.1"
django-q = "^1.3.9"
django-q2 = "^1.7.4"
croniter = "^5.0.1"
requests = "^2.32.3"
pyyaml = "^6.0.2"
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View File

@ -51,7 +51,7 @@ Q_CLUSTER = {
"recycle": 500, "recycle": 500,
"timeout": 60, "timeout": 60,
"retry": 120, "retry": 120,
"orm": "default", "scheduler": True,
} }
GRAPHENE = {"SCHEMA": "games.schema.schema"} GRAPHENE = {"SCHEMA": "games.schema.schema"}