38 Commits

Author SHA1 Message Date
7111431048 Initial commit
Some checks failed
Django CI/CD / test (push) Failing after 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2025-01-29 12:43:57 +01:00
c2853a3ecc purchases can now refer to multiple editions
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m36s
allows purchases to be for more than one game
2025-01-08 21:00:19 +01:00
cd90d60475 fix monthly playtime not displaying
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m12s
this bug was introduced in d622ddfbf3
2024-12-01 11:00:15 +01:00
11cea2142a dont display year if none
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m15s
2024-11-27 18:47:25 +01:00
24578b64fe disable djlint precommit hook 2024-11-27 18:47:16 +01:00
13e607f9a7 Add error handling if no Sessions exist
All checks were successful
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Successful in 2m30s
2024-11-27 18:35:44 +01:00
fc0d8db8e8 consistently format prices everywhere
All checks were successful
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-11-15 22:53:07 +01:00
8acc4f9c5b make table work better on small screens
All checks were successful
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m12s
2024-11-13 21:28:44 +01:00
6b7a96dc06 make PopoverTruncated customizable 2024-11-13 21:28:17 +01:00
5c5fd5f26a truncate: strip trailing whitespace 2024-11-13 21:07:26 +01:00
7181b6472c fix mistakenly hardcoded value in truncate() 2024-11-13 21:06:52 +01:00
af06d07ee3 pre-commit hook: disable isort 2024-11-13 21:06:38 +01:00
315e22a8ac Add yaml to dependencies
All checks were successful
Django CI/CD / test (push) Successful in 1m28s
Django CI/CD / build-and-push (push) Successful in 2m33s
2024-11-11 18:14:48 +01:00
19676f8441 Implement converting prices (#79)
All checks were successful
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m10s
Reviewed-on: #79
2024-11-11 16:36:57 +00:00
f61cde180f Pass search_string to search_field.html
All checks were successful
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-11-10 00:05:33 +01:00
a53818257c Fix being unable to override c-vars from render_from_string 2024-11-10 00:05:11 +01:00
2d3ea714c4 Extend session search 2024-11-09 23:52:09 +01:00
832bb48983 Device: safe long type names directly in database 2024-11-09 23:51:28 +01:00
c6b1badf39 add session search
All checks were successful
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m16s
2024-11-09 21:34:01 +00:00
a3ed93c154 handle non-existent icons
Some checks failed
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been cancelled
2024-11-09 21:30:13 +00:00
cf503a7b7d improve devcontainer
All checks were successful
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:56:20 +01:00
d81df6452a add dev container
All checks were successful
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:22:21 +01:00
d9290373b0 also sort purchases by created_at
All checks were successful
Django CI/CD / test (push) Successful in 59s
Django CI/CD / build-and-push (push) Successful in 2m24s
2024-10-18 09:50:10 +02:00
f8d621e710 fix stat dropdown
All checks were successful
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m7s
2024-10-16 18:31:12 +02:00
9992d9c9bd set edition platform to unspecified if none 2024-10-16 18:06:40 +02:00
2ae81bb00f update django-cotton to 1.2.1 2024-10-16 17:49:55 +02:00
993abb4710 editorconfig: do not add newline to HTML 2024-10-16 17:45:23 +02:00
23502eab85 do not throw error when no stats to calculate 2024-10-16 17:45:23 +02:00
c517d735c7 use unified dateformat more 2024-10-16 17:45:23 +02:00
19056f846e view_game: display timezone-aware time for end timestamp 2024-10-16 17:45:23 +02:00
0759ad0804 make purchase price a float 2024-10-16 17:45:23 +02:00
228fc2bf5f avoid exception on game overview when sessions are 0 2024-10-16 17:45:23 +02:00
a5a7041920 rename icon 2024-10-16 17:45:23 +02:00
fbd829f70e order platforms by name 2024-10-16 17:45:23 +02:00
4873f25248 remove css cruft 2024-10-16 17:45:23 +02:00
3578f1707f add more icons 2024-10-16 17:45:23 +02:00
b74ccb6eaa Remove extraneous statement 2024-10-16 17:45:23 +02:00
b0b1bb2d42 add icon field to platform, use everywhere 2024-10-16 17:45:23 +02:00
52 changed files with 2068 additions and 1043 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -2,11 +2,14 @@ from random import choices as random_choices
from string import ascii_lowercase
from typing import Any, Callable
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate
from games.models import Purchase
HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str
@ -49,6 +52,7 @@ def randomid(seed: str = "", length: int = 10) -> str:
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
@ -61,17 +65,43 @@ def Popover(
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
],
children=children,
template="cotton/popover.html",
)
def PopoverTruncated(input_string: str) -> str:
if (truncated := truncate(input_string)) != input_string:
return Popover(wrapped_content=truncated, popover_content=input_string)
def PopoverTruncated(
input_string: str,
popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
The truncated text ends in `ellipsis`, and optionally
an always-visible `endpart` can be specified.
`popover_content` can be specified if:
1. It needs to be always displayed regardless if text is truncated.
2. It needs to differ from `input_string`.
"""
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
return Popover(
wrapped_content=truncated,
popover_content=popover_content if popover_content else input_string,
)
else:
return input_string
if popover_content and popover_if_not_truncated:
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
else:
return input_string
def A(
@ -124,11 +154,38 @@ def Div(
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Form(
action="",
method="get",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="form",
attributes=attributes + [("action", action), ("method", method)],
children=children,
)
def Icon(
name: str,
attributes: list[HTMLAttribute] = [],
):
return Component(template=f"cotton/icon/{name}.html", attributes=attributes)
try:
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
except TemplateDoesNotExist:
result = Icon(name="unspecified", attributes=attributes)
return result
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
@ -152,6 +209,47 @@ 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:
content = Div(
[("class", "inline-flex gap-2 items-center")],
@ -165,3 +263,11 @@ def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
)
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

@ -163,3 +163,7 @@ def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]
else:
increment_streak()
return {"days": highest_streak, "dates": highest_streak_daterange}
def available_stats_year_range():
return range(datetime.now().year, 1999, -1)

View File

@ -34,14 +34,31 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
return obj
def truncate(input_string: str, length: int = 30, ellipsis: str = "") -> str:
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > 30
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
else input_string
)
def truncate(
input_string: str, length: int = 30, ellipsis: str = "", endpart: str = ""
) -> str:
max_content_length = length - len(endpart)
if max_content_length < 0:
raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length:
return f"{input_string[:max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return (
f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length
else f"{input_string[:length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
)
T = TypeVar("T", str, int, date)

24
devcontainer.Dockerfile Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,24 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from django_q.models import Schedule
from django_q.tasks import schedule
class Command(BaseCommand):
help = "Manually schedule the next update_converted_prices task"
def handle(self, *args, **kwargs):
if not Schedule.objects.filter(name="Update converted prices").exists():
schedule(
"games.tasks.convert_prices",
name="Update converted prices",
schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30),
)
self.stdout.write(
self.style.SUCCESS("Scheduled the update_converted_prices task.")
)
else:
self.stdout.write(self.style.WARNING("Task is already scheduled."))

View File

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

View File

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

View File

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

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

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

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

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

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

@ -0,0 +1,61 @@
# 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,20 +9,6 @@ from django.utils import timezone
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):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
@ -38,6 +24,32 @@ class Platform(models.Model):
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class 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 Meta:
unique_together = [["name", "platform", "year_released"]]
@ -55,6 +67,11 @@ class Edition(models.Model):
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
@ -102,7 +119,7 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
editions = models.ManyToManyField(Game, related_name="purchases", blank=True)
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
@ -113,6 +130,8 @@ class Purchase(models.Model):
infinite = models.BooleanField(default=False)
price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, null=True)
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
@ -128,29 +147,41 @@ class Purchase(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
@property
def first_edition(self):
return self.editions.first()
def __str__(self):
additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "",
(
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
f"{self.first_edition.platform} version on {self.platform}"
if self.platform != self.first_edition.platform
else self.platform
),
self.edition.year_released,
self.first_edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
return f"{self.first_edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
if self.pk is not None:
# Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields
if (
existing_purchase.price != self.price
or existing_purchase.price_currency != self.price_currency
):
self.converted_price = None
self.converted_currency = None
super().save(*args, **kwargs)
@ -248,12 +279,12 @@ class Session(models.Model):
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
PC = "PC"
CONSOLE = "Console"
HANDHELD = "Handheld"
MOBILE = "Mobile"
SBC = "Single-board computer"
UNKNOWN = "Unknown"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
@ -263,8 +294,21 @@ class Device(models.Model):
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"
return f"{self.name} ({self.type})"
class ExchangeRate(models.Model):
currency_from = models.CharField(max_length=255)
currency_to = models.CharField(max_length=255)
year = models.PositiveIntegerField()
rate = models.FloatField()
class Meta:
unique_together = ("currency_from", "currency_to", "year")
def __str__(self):
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"

View File

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

57
games/tasks.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,40 +1,36 @@
{% extends "base.html" %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container" hx-boost="false">
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button"
data-target="{{ field.name }}"
data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
<c-layouts.add>
<c-slot name="form_content">
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<td></td>
<td>
<input type="submit" value="Submit" />
</td>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container" hx-boost="false">
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button"
data-target="{{ field.name }}"
data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
</div>
</td>
{% endif %}
</tr>
</table>
</form>
{% load static %}
<script type="module" src="{% static 'js/add_session.js' %}"></script>
{% endblock content %}
{% endfor %}
<tr>
<td></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>
</c-slot>
</c-layouts.add>

View File

@ -0,0 +1,25 @@
<c-layouts.base>
{% load static %}
{% if form_content %}
{{ form_content }}
{% else %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
{{ additional_row }}
</table>
</form>
{% endif %}
<c-slot name="scripts">
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
</c-slot>
</c-layouts.base>

View File

@ -7,11 +7,7 @@
<meta name="description" content="Self-hosted time-tracker." />
<meta name="keywords" content="time, tracking, video games, self-hosted" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker -
{% block title %}
Untitled
{% endblock title %}
</title>
<title>Timetracker - {{ title }}</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" />
@ -34,16 +30,11 @@
alt="loading indicator" />
<div class="flex flex-col min-h-screen">
{% include "navbar.html" %}
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">
{% block content %}
No content here.
{% endblock content %}
</div>
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">{{ slot }}</div>
{% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div>
{% block scripts %}
{% endblock scripts %}
{{ scripts }}
<script>
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');

View File

@ -1,8 +1,10 @@
<span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span>
<div data-popover
id="{{ id }}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
<div class="px-3 py-2">{{ popover_content }}</div>
<div data-popper-arrow></div>
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
<span class="hidden decoration-dotted"></span>
</div>

View File

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

View File

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

View File

@ -1,10 +1,6 @@
{% extends "base.html" %}
<c-layouts.base>
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
{% endblock content %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
</c-layouts.base>

View File

@ -122,7 +122,7 @@
</div>
</li>
<li>
<a href="{% url 'stats_by_year' 0 %}"
<a href="{% url 'stats_by_year' global_current_year %}"
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li>
<li>

View File

@ -1,22 +1,18 @@
{% extends "base.html" %}
<c-layouts.base title="Login">
{% load static %}
{% block title %}
Login
{% endblock title %}
{% block content %}
<div class="flex items-center flex-col">
<h2 class="text-3xl text-white mb-8">Please log in to continue</h2>
<form method="post">
<table>
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Login" />
</td>
</tr>
</form>
</table>
</div>
{% endblock content %}
<div class="flex items-center flex-col">
<h2 class="text-3xl text-white mb-8">Please log in to continue</h2>
<form method="post">
<table>
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Login" />
</td>
</tr>
</form>
</table>
</div>
</c-layouts.base>

View File

@ -1,279 +1,287 @@
{% extends "base.html" %}
{% block title %}
{{ title }}
{% endblock title %}
<c-layouts.base>
{% load static %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.edition.game.id>
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
<c-gamelink :game_id=purchase.first_edition.game.id>
{{ purchase.name }} ({{ purchase.first_edition.edition.name }} {{ purchase.get_type_display }})
</c-gamelink>
{% else %}
<c-gamelink :game_id=purchase.edition.game.id :name=purchase.edition.name />
<c-gamelink :game_id=purchase.first_edition.game.id :name=purchase.first_edition.name />
{% endif %}
{% endpartialdef %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year"
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year"
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
{% if total_games %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr>
{% if all_finished_this_year_count %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
{% if total_games %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr>
{% if all_finished_this_year_count %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
</tr>
{% if longest_session_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ longest_session_time }} (<c-gamelink :game_id=longest_session_game.id :name=longest_session_game.name />)
</td>
</tr>
{% endif %}
{% if highest_session_count_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ highest_session_count }} (<c-gamelink :game_id=highest_session_count_game.id :name=highest_session_count_game.name />)
</td>
</tr>
{% endif %}
{% if highest_session_average_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ highest_session_average }} (<c-gamelink :game_id=highest_session_average_game.id :name=highest_session_average_game.name />)
</td>
</tr>
{% endif %}
{% if first_play_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=first_play_game.id :name=first_play_game.name /> ({{ first_play_date }})
</td>
</tr>
{% endif %}
{% if last_play_game.id %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=last_play_game.id :name=last_play_game.name /> ({{ last_play_date }})
</td>
</tr>
</tbody>
</table>
{% if month_playtime %}
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table">
<tbody>
{% for month in month_playtimes %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h1 class="text-5xl text-center my-6">Purchases</h1>
{% endif %}
</tbody>
</table>
{% if month_playtimes %}
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ dropped_count }} ({{ dropped_percentage }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for game in top_10_games_by_playtime %}
{% for month in month_playtimes %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=game.id :name=game.name />
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
{% endif %}
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ dropped_count }} ({{ dropped_percentage }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
</td>
</tr>
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=game.id :name=game.name />
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for item in total_playtime_per_platform %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if all_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for item in total_playtime_per_platform %}
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if all_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
{% endif %}
{% if this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_this_year %}
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
</thead>
<tbody>
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<table class="responsive-table">
<thead>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_unfinished %}
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_unfinished %}
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
<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.date_purchased | date:"d/m/Y" }}</td>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_unfinished %}
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<table class="responsive-table">
<thead>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if all_purchased_this_year %}
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
<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.date_purchased | date:"d/m/Y" }}</td>
</tr>
</thead>
<tbody>
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if all_purchased_this_year %}
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endblock content %}
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</c-layouts.base>

View File

@ -1,94 +1,88 @@
{% extends "base.html" %}
{% block title %}
{{ title }}
{% endblock title %}
{% load static %}
{% load markdown_extras %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>
</span>
</div>
<div class="flex gap-4 dark:text-slate-400 mb-3">
<c-popover id="popover-hours" popover_content="Total hours played" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{{ hours_sum }}
</c-popover>
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
</svg>
{{ session_count }}
</c-popover>
<c-popover id="popover-average" popover_content="Average playtime per session" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
</svg>
{{ session_average_without_manual }}
</c-popover>
<c-popover id="popover-playrange" popover_content="Earliest and latest dates played" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
</svg>
{{ playrange }}
</c-popover>
</div>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_game' game.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_game' game.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>
<c-layouts.base>
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
</span>
</div>
<c-h1 :badge=edition_count>Editions</c-h1>
<div class="mb-6">
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
<div class="flex gap-4 dark:text-slate-400 mb-3">
<c-popover id="popover-hours" popover_content="Total hours played" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{{ hours_sum }}
</c-popover>
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
</svg>
{{ session_count }}
</c-popover>
<c-popover id="popover-average" popover_content="Average playtime per session" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
</svg>
{{ session_average_without_manual }}
</c-popover>
<c-popover id="popover-playrange" popover_content="Earliest and latest dates played" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
</svg>
{{ playrange }}
</c-popover>
</div>
<div class="mb-6">
<c-h1 :badge=purchase_count>Purchases</c-h1>
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />
</div>
<div class="mb-6">
<c-h1 :badge=session_count>Sessions</c-h1>
<c-simple-table :rows=session_data.rows :columns=session_data.columns :header_action=session_data.header_action :page_obj=session_page_obj :elided_page_range=session_elided_page_range />
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_game' game.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_game' game.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>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
{% endblock content %}
<c-h1 :badge="edition_count">Editions</c-h1>
<div class="mb-6">
<c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
</div>
<div class="mb-6">
<c-h1 :badge="purchase_count">Purchases</c-h1>
<c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />
</div>
<div class="mb-6">
<c-h1 :badge="session_count">Sessions</c-h1>
<c-simple-table :rows=session_data.rows :columns=session_data.columns :header_action=session_data.header_action :page_obj=session_page_obj :elided_page_range=session_elided_page_range />
</div>
</div>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
</c-layouts.base>

View File

@ -0,0 +1,34 @@
<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,6 +54,11 @@ urlpatterns = [
purchase.delete_purchase,
name="delete_purchase",
),
path(
"purchase/<int:purchase_id>/view",
purchase.view_purchase,
name="view_purchase",
),
path(
"purchase/<int:purchase_id>/finish",
purchase.finish_purchase,
@ -116,6 +121,7 @@ urlpatterns = [
name="list_sessions_end_session",
),
path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"),
path("stats/", general.stats_alltime, name="stats_alltime"),
path(
"stats/<int:year>",

View File

@ -47,7 +47,6 @@ def list_editions(request: HttpRequest) -> HttpResponse:
"Game",
"Name",
"Sort Name",
"Platform",
"Year",
"Wikidata",
"Created",

View File

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

View File

@ -1,14 +1,15 @@
from datetime import datetime
from typing import Any, Callable
from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
from django.db.models.functions import TruncDate, TruncMonth
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse
from common.time import dateformat, format_duration
from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division
from games.models import Edition, Game, Platform, Purchase, Session
@ -23,6 +24,10 @@ def model_counts(request: HttpRequest) -> dict[str, bool]:
}
def global_current_year(request: HttpRequest) -> dict[str, int]:
return {"global_current_year": datetime.now().year}
def use_custom_redirect(
func: Callable[..., HttpResponse],
) -> Callable[..., HttpResponse]:
@ -44,7 +49,9 @@ def use_custom_redirect(
@login_required
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().select_related("purchase__edition")
this_year_sessions = Session.objects.all().prefetch_related(
Prefetch("purchase__editions")
)
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -53,10 +60,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions
editions__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("edition__purchase__session"),
session_count=Count("editions__purchase__session"),
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
@ -73,9 +80,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
).distinct()
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_with_currency = this_year_purchases.select_related("editions")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
@ -119,16 +124,16 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
Game.objects.filter(editions__purchase__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
F("editions__purchase__session__duration_calculated")
+ F("editions__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -143,9 +148,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
Game.objects.filter(editions__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchase__session__duration_calculated")
session_average=Avg("editions__purchase__session__duration_calculated")
)
.order_by("-session_average")
.first()
@ -172,10 +177,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
last_play_date = "N/A"
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game
first_play_game = first_session.purchase.first_edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game
last_play_game = last_session.purchase.first_edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -224,7 +229,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
else 0
),
"longest_session_game": (
longest_session.purchase.edition.game if longest_session else None
longest_session.purchase.first_edition.game if longest_session else None
),
"highest_session_count": (
game_highest_session_count.session_count
@ -247,6 +252,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
"last_play_game": last_play_game,
"last_play_date": last_play_date,
"title": f"{year} Stats",
"stats_dropdown_year_range": available_stats_year_range(),
}
request.session["return_path"] = request.path
@ -262,7 +268,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).select_related("purchase__edition")
).prefetch_related("purchase__editions")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -271,12 +277,12 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions
edition__purchases__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"edition__purchase__session",
filter=Q(edition__purchase__session__timestamp_start__year=year),
"edition__purchases__session",
filter=Q(edition__purchases__session__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
@ -294,12 +300,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_with_currency = this_year_purchases.prefetch_related("editions")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
).exclude(ownership_type=Purchase.DEMO)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
@ -333,7 +337,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by(
purchases_finished_this_year.filter(editions__year_released=year).order_by(
"date_finished"
)
)
@ -342,16 +346,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
Game.objects.filter(edition__purchases__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
F("edition__purchases__session__duration_calculated")
+ F("edition__purchases__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -366,9 +370,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
Game.objects.filter(edition__purchases__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchase__session__duration_calculated")
session_average=Avg("edition__purchases__session__duration_calculated")
)
.order_by("-session_average")
.first()
@ -399,10 +403,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game
first_play_game = first_session.purchase.first_edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game
last_play_game = last_session.purchase.first_edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -419,7 +423,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
edition__year_released=year
editions__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
@ -430,16 +434,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"all_finished_this_year": purchases_finished_this_year.select_related(
"edition"
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
"editions"
).order_by("date_finished"),
"all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
"edition"
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"editions"
).order_by("date_finished"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related(
"edition"
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"editions"
).order_by("date_finished"),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
@ -469,7 +473,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
else 0
),
"longest_session_game": (
longest_session.purchase.edition.game if longest_session else None
longest_session.purchase.first_edition.game if longest_session else None
),
"highest_session_count": (
game_highest_session_count.session_count
@ -493,6 +497,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"last_play_date": last_play_date,
"title": f"{year} Stats",
"month_playtimes": month_playtimes,
"stats_dropdown_year_range": available_stats_year_range(),
}
request.session["return_path"] = request.path

View File

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

View File

@ -2,13 +2,22 @@ from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from common.components import A, Button, Div, Icon, LinkedNameWithPlatformIcon, Popover
from common.components import (
A,
Button,
Div,
Form,
Icon,
LinkedNameWithPlatformIcon,
Popover,
)
from common.time import (
dateformat,
durationformat,
@ -24,12 +33,24 @@ from games.views.general import use_custom_redirect
@login_required
def list_sessions(request: HttpRequest) -> HttpResponse:
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start")
last_session = sessions.latest()
search_string = request.GET.get("search_string", search_string)
if search_string != "":
sessions = sessions.filter(
Q(purchase__edition__name__icontains=search_string)
| Q(purchase__edition__game__name__icontains=search_string)
| Q(purchase__platform__name__icontains=search_string)
| Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string)
)
try:
last_session = sessions.latest()
except Session.DoesNotExist:
last_session = None
page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
@ -49,37 +70,55 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
"data": {
"header_action": Div(
children=[
A(
url="add_session",
children=Button(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
Form(
children=[
render_to_string(
"cotton/search_field.html",
{
"id": "search_string",
"search_string": search_string,
},
)
]
),
A(
url=reverse(
"list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.edition.name,
children=[
Button(
Div(
children=[
A(
url="add_session",
children=Button(
icon=True,
color="gray",
size="xs",
children=[Icon("play"), "LOG"],
),
),
A(
url=reverse(
"list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.first_edition.name,
children=[
Icon("play"),
truncate(
f"{last_session.purchase.edition.name}"
),
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(
f"{last_session.purchase.first_edition.name}"
),
],
)
],
)
],
),
),
)
if last_session
else "",
]
),
],
attributes=[("class", "flex justify-between")],
),
"columns": [
"Name",
@ -92,8 +131,8 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
"rows": [
[
LinkedNameWithPlatformIcon(
name=session.purchase.edition.name,
game_id=session.purchase.edition.game.pk,
name=session.purchase.first_edition.name,
game_id=session.purchase.first_edition.game.pk,
platform=session.purchase.platform,
),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
@ -150,6 +189,11 @@ def list_sessions(request: HttpRequest) -> HttpResponse:
return render(request, "list_purchases.html", context)
@login_required
def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
@login_required
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
context = {}
@ -177,6 +221,7 @@ def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
form = SessionForm(initial=initial)
context["title"] = "Add New Session"
context["script_name"] = "add_session.js"
context["form"] = form
return render(request, "add_session.html", context)

View File

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

703
poetry.lock generated
View File

@ -1,18 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aniso8601"
version = "9.0.1"
description = "A library for parsing ISO 8601 strings."
optional = false
python-versions = "*"
files = [
{file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"},
{file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"},
]
[package.extras]
dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"]
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "asgiref"
@ -29,26 +15,16 @@ files = [
tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
[[package]]
name = "beautifulsoup4"
version = "4.12.3"
description = "Screen-scraping library"
name = "certifi"
version = "2024.8.30"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6.0"
python-versions = ">=3.6"
files = [
{file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
{file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[package.dependencies]
soupsieve = ">1.2"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "cfgv"
version = "3.4.0"
@ -60,6 +36,120 @@ files = [
{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]]
name = "click"
version = "8.1.7"
@ -85,6 +175,21 @@ files = [
{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]]
name = "cssbeautifier"
version = "1.15.1"
@ -102,24 +207,24 @@ six = ">=1.13.0"
[[package]]
name = "distlib"
version = "0.3.8"
version = "0.3.9"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
]
[[package]]
name = "django"
version = "5.1.1"
version = "5.1.3"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.10"
files = [
{file = "Django-5.1.1-py3-none-any.whl", hash = "sha256:71603f27dac22a6533fb38d83072eea9ddb4017fead6f67f2562a40402d61c3f"},
{file = "Django-5.1.1.tar.gz", hash = "sha256:021ffb7fdab3d2d388bc8c7c2434eb9c1f6f4d09e6119010bbb1694dda286bc2"},
{file = "Django-5.1.3-py3-none-any.whl", hash = "sha256:8b38a9a12da3ae00cb0ba72da985ec4b14de6345046b1e174b1fd7254398f818"},
{file = "Django-5.1.3.tar.gz", hash = "sha256:c0fa0e619c39325a169208caef234f90baa925227032ad3f44842ba14d75234a"},
]
[package.dependencies]
@ -133,17 +238,17 @@ bcrypt = ["bcrypt"]
[[package]]
name = "django-cotton"
version = "0.9.37"
version = "1.3.0"
description = "Bringing component based design to Django templates."
optional = false
python-versions = "<4,>=3.8"
files = [
{file = "django_cotton-0.9.37-py3-none-any.whl", hash = "sha256:b917102bee417eb749df25eae75a985db0749fb8ac9711ab57fb5f78de03d8e8"},
{file = "django_cotton-0.9.37.tar.gz", hash = "sha256:0ccfc76d6edfc8dfaf0bb5d960c6695fff78cbcc1a3c52e263a0484b8dbbb9a5"},
{file = "django_cotton-1.3.0-py3-none-any.whl", hash = "sha256:a23f29b759c43423e2f901352c0810388839cc412f6985614153c6ccfcfc2595"},
{file = "django_cotton-1.3.0.tar.gz", hash = "sha256:8f4a15dd55c8ee9182cf7234c228ea45d9fcdec1de125221bce8d05af035730a"},
]
[package.dependencies]
beautifulsoup4 = ">=4.12.2,<4.13.0"
django = ">=4.2,<5.2"
[[package]]
name = "django-debug-toolbar"
@ -176,18 +281,55 @@ Django = ">=3.2"
[[package]]
name = "django-htmx"
version = "1.19.0"
version = "1.21.0"
description = "Extensions for using Django with htmx."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "django_htmx-1.19.0-py3-none-any.whl", hash = "sha256:875a642814e52278c1728842436beda2001847a493ab79fd82da3fb46ead140f"},
{file = "django_htmx-1.19.0.tar.gz", hash = "sha256:e7e17304e78e07f96eca0affc3ce1806edfdf3538bb7cb1912452b101f3e627d"},
{file = "django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:64bc31463017a80552b767bc216ee5700248fa72e7ccd2963495e69afbdb6abe"},
{file = "django_htmx-1.21.0.tar.gz", hash = "sha256:6ed3b42effd5980f22e68f36cd14ee4311bff3b6cb8435a89e27f45995691572"},
]
[package.dependencies]
asgiref = ">=3.6"
django = ">=3.2"
django = ">=4.2"
[[package]]
name = "django-picklefield"
version = "3.2"
description = "Pickled object field for Django"
optional = false
python-versions = ">=3"
files = [
{file = "django-picklefield-3.2.tar.gz", hash = "sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d"},
{file = "django_picklefield-3.2-py3-none-any.whl", hash = "sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c"},
]
[package.dependencies]
Django = ">=3.2"
[package.extras]
tests = ["tox"]
[[package]]
name = "django-q2"
version = "1.7.4"
description = "A multiprocessing distributed task queue for Django"
optional = false
python-versions = "<4,>=3.8"
files = [
{file = "django_q2-1.7.4-py3-none-any.whl", hash = "sha256:6eda6d56505822ee5ebc6c4eac1dde726f5dbf20ee9ea7080575535852e2671f"},
{file = "django_q2-1.7.4.tar.gz", hash = "sha256:56a3781cc480474fa9c04bbde62445b0a9b4195adc409bd963b8f593b0598c43"},
]
[package.dependencies]
django = ">=4.2,<6"
django-picklefield = ">=3.1,<4.0"
[package.extras]
rollbar = ["django-q-rollbar (>=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)"]
[[package]]
name = "django-template-partials"
@ -209,12 +351,12 @@ tests = ["coverage", "django_coverage_plugin"]
[[package]]
name = "djhtml"
version = "3.0.6"
version = "3.0.7"
description = "Django/Jinja template indenter"
optional = false
python-versions = "*"
files = [
{file = "djhtml-3.0.6.tar.gz", hash = "sha256:abfc4d7b4730432ca6a98322fbdf8ae9d6ba254ea57ba3759a10ecb293bc57de"},
{file = "djhtml-3.0.7.tar.gz", hash = "sha256:558c905b092a0c8afcbed27dea2f50aa6eb853a658b309e4e0f2bb378bdf6178"},
]
[package.extras]
@ -222,25 +364,43 @@ dev = ["nox", "pre-commit"]
[[package]]
name = "djlint"
version = "1.35.2"
version = "1.36.1"
description = "HTML Template Linter and Formatter"
optional = false
python-versions = "<4.0,>=3.8"
python-versions = ">=3.9"
files = [
{file = "djlint-1.35.2-py3-none-any.whl", hash = "sha256:4ba995bad378f2afa77c8ea56ba1c14429d9ff26a18e8ae23bc71eedb9152243"},
{file = "djlint-1.35.2.tar.gz", hash = "sha256:318de9d4b9b0061a111f8f5164ecbacd8215f449dd4bd5a76d2a691c815ee103"},
{file = "djlint-1.36.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef40527fd6cd82cdd18f65a6bf5b486b767d2386f6c21f2ebd60e5d88f487fe8"},
{file = "djlint-1.36.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4712de3dea172000a098da6a0cd709d158909b4964ba0f68bee584cef18b4878"},
{file = "djlint-1.36.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d01c1425170b7059d68a3b01709e1c31d2cd6520a1eb0166e6670fd250518a"},
{file = "djlint-1.36.1-cp310-cp310-win_amd64.whl", hash = "sha256:65585a97d3a37760b4c1fbd089a3573506ad0ab2885119322a66231f911d113f"},
{file = "djlint-1.36.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:607437a0a230462916858c269bc5dfd15ff71b27d15dfd1ad6e96b3da9cbd8f6"},
{file = "djlint-1.36.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ddc9ae6b83b288465f6685b24797adbde79952d6e1a5276026e5ef479bac76f"},
{file = "djlint-1.36.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5124b0ebab60a2044134abd11ff11dee772e7c3caaa2c8d12eb5d3b1f1dc"},
{file = "djlint-1.36.1-cp311-cp311-win_amd64.whl", hash = "sha256:095d62f3cabbac08683c51c1d9dacab522b54437a2a317df9e134599360f7b89"},
{file = "djlint-1.36.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:210f319c2d22489aebc0e9c1acd5015ca3892b66fa35647344511b3c03fcbe82"},
{file = "djlint-1.36.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7aa3db13d7702c35f4e408325061d9d4e84d006c99bb3e55fddf2b2543736923"},
{file = "djlint-1.36.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f907e97f4d67f4423dc71671592891cfd9cd311aeef14db25633f292dbf7048"},
{file = "djlint-1.36.1-cp312-cp312-win_amd64.whl", hash = "sha256:abadf6b61dc53d81710f230542f57f2d470b7503cd3108ad8a0113271c0514dd"},
{file = "djlint-1.36.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7f31646435385eec1d4b03dad7bebb5e4078d9893c60d490a685535bd6303c83"},
{file = "djlint-1.36.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4399477ac51f9c8147eedbef70aa8465eccba6759d875d1feec6782744aa168a"},
{file = "djlint-1.36.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c217b17d3ae3c0e3b5fff57fb708029cceda6e232f5a54ff1b3aeb43a7540"},
{file = "djlint-1.36.1-cp313-cp313-win_amd64.whl", hash = "sha256:1577490802ca4697af3488ed13066c9214ef0f625a96aa20d4f297e37aa19303"},
{file = "djlint-1.36.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ae356faf8180c7629ca705b7b9d8c9269b2c53273a1887a438a21b8fa263588"},
{file = "djlint-1.36.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2237ac5cecd2524960e1684f64ce358624b0d769b7404e5aad415750ad00edc9"},
{file = "djlint-1.36.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02c22352a49c053ff6260428ed571afb783011d20afc98b44bbe1dd2fa2d5418"},
{file = "djlint-1.36.1-cp39-cp39-win_amd64.whl", hash = "sha256:99a2debeea2e931b68360306fdbf13861e3d6f96037a9d882f3d4d5e44fdc319"},
{file = "djlint-1.36.1-py3-none-any.whl", hash = "sha256:950782b396dd82b74622c09d7e4c52328e56a3b03c8ac790c319708e5caa0686"},
{file = "djlint-1.36.1.tar.gz", hash = "sha256:f7260637ed72c270fa6dd4a87628e1a21c49b24a46df52e4e26f44d4934fb97c"},
]
[package.dependencies]
click = ">=8.0.1"
colorama = ">=0.4.4"
cssbeautifier = ">=1.14.4"
html-tag-names = ">=0.1.2"
html-void-elements = ">=0.1.0"
jsbeautifier = ">=1.14.4"
json5 = ">=0.9.11"
pathspec = ">=0.12.0"
PyYAML = ">=6.0"
pathspec = ">=0.12"
pyyaml = ">=6"
regex = ">=2023"
tqdm = ">=4.62.2"
@ -256,39 +416,40 @@ files = [
[[package]]
name = "filelock"
version = "3.16.0"
version = "3.16.1"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.8"
files = [
{file = "filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609"},
{file = "filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec"},
{file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
{file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
]
[package.extras]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.3)"]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
typing = ["typing-extensions (>=4.12.2)"]
[[package]]
name = "graphene"
version = "3.3"
version = "3.4.3"
description = "GraphQL Framework for Python"
optional = false
python-versions = "*"
files = [
{file = "graphene-3.3-py2.py3-none-any.whl", hash = "sha256:bb3810be33b54cb3e6969506671eb72319e8d7ba0d5ca9c8066472f75bf35a38"},
{file = "graphene-3.3.tar.gz", hash = "sha256:529bf40c2a698954217d3713c6041d69d3f719ad0080857d7ee31327112446b0"},
{file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"},
{file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"},
]
[package.dependencies]
aniso8601 = ">=8,<10"
graphql-core = ">=3.1,<3.3"
graphql-relay = ">=3.1,<3.3"
python-dateutil = ">=2.7.0,<3"
typing-extensions = ">=4.7.1,<5"
[package.extras]
dev = ["black (==22.3.0)", "coveralls (>=3.3,<4)", "flake8 (>=4,<5)", "iso8601 (>=1,<2)", "mock (>=4,<5)", "pytest (>=6,<7)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=3.4,<4)", "pytest-cov (>=3,<4)", "pytest-mock (>=3,<4)", "pytz (==2022.1)", "snapshottest (>=0.6,<1)"]
test = ["coveralls (>=3.3,<4)", "iso8601 (>=1,<2)", "mock (>=4,<5)", "pytest (>=6,<7)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=3.4,<4)", "pytest-cov (>=3,<4)", "pytest-mock (>=3,<4)", "pytz (==2022.1)", "snapshottest (>=0.6,<1)"]
dev = ["coveralls (>=3.3,<5)", "mypy (>=1.10,<2)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)", "ruff (==0.5.0)", "types-python-dateutil (>=2.8.1,<3)"]
test = ["coveralls (>=3.3,<5)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)"]
[[package]]
name = "graphene-django"
@ -316,13 +477,13 @@ test = ["coveralls", "django-filter (>=22.1)", "djangorestframework (>=3.6.3)",
[[package]]
name = "graphql-core"
version = "3.2.4"
version = "3.2.5"
description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
optional = false
python-versions = "<4,>=3.6"
files = [
{file = "graphql-core-3.2.4.tar.gz", hash = "sha256:acbe2e800980d0e39b4685dd058c2f4042660b89ebca38af83020fd872ff1264"},
{file = "graphql_core-3.2.4-py3-none-any.whl", hash = "sha256:1604f2042edc5f3114f49cac9d77e25863be51b23a54a61a23245cf32f6476f0"},
{file = "graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a"},
{file = "graphql_core-3.2.5.tar.gz", hash = "sha256:e671b90ed653c808715645e3998b7ab67d382d55467b7e2978549111bbabf8d5"},
]
[[package]]
@ -371,42 +532,34 @@ files = [
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "html-tag-names"
version = "0.1.2"
description = "List of known HTML tag names"
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "html-tag-names-0.1.2.tar.gz", hash = "sha256:04924aca48770f36b5a41c27e4d917062507be05118acb0ba869c97389084297"},
{file = "html_tag_names-0.1.2-py3-none-any.whl", hash = "sha256:eeb69ef21078486b615241f0393a72b41352c5219ee648e7c61f5632d26f0420"},
]
[[package]]
name = "html-void-elements"
version = "0.1.0"
description = "List of HTML void tag names."
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "html-void-elements-0.1.0.tar.gz", hash = "sha256:931b88f84cd606fee0b582c28fcd00e41d7149421fb673e1e1abd2f0c4f231f0"},
{file = "html_void_elements-0.1.0-py3-none-any.whl", hash = "sha256:784cf39db03cdeb017320d9301009f8f3480f9d7b254d0974272e80e0cb5e0d2"},
]
[[package]]
name = "identify"
version = "2.6.0"
version = "2.6.2"
description = "File identification library for Python"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"},
{file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"},
{file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"},
{file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"},
]
[package.extras]
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]]
name = "iniconfig"
version = "2.0.0"
@ -474,38 +627,43 @@ testing = ["coverage", "pyyaml"]
[[package]]
name = "mypy"
version = "1.11.2"
version = "1.13.0"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"},
{file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"},
{file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"},
{file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"},
{file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"},
{file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"},
{file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"},
{file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"},
{file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"},
{file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"},
{file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"},
{file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"},
{file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"},
{file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"},
{file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"},
{file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"},
{file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"},
{file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"},
{file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"},
{file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"},
{file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"},
{file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"},
{file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"},
{file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"},
{file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"},
{file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"},
{file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"},
{file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"},
{file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"},
{file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"},
{file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"},
{file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"},
{file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"},
{file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"},
{file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"},
{file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"},
{file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"},
{file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"},
{file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"},
{file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"},
{file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"},
{file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"},
{file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"},
{file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"},
{file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"},
{file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"},
{file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"},
{file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"},
{file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"},
{file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"},
{file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"},
{file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"},
{file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"},
{file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"},
{file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"},
{file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"},
{file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"},
{file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"},
{file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"},
]
[package.dependencies]
@ -514,6 +672,7 @@ typing-extensions = ">=4.6.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
faster-cache = ["orjson"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
@ -542,13 +701,13 @@ files = [
[[package]]
name = "packaging"
version = "24.1"
version = "24.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
]
[[package]]
@ -564,19 +723,19 @@ files = [
[[package]]
name = "platformdirs"
version = "4.3.1"
version = "4.3.6"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.3.1-py3-none-any.whl", hash = "sha256:facaa5a3c57aa1e053e3da7b49e0cc31fe0113ca42a4659d5c2e98e545624afe"},
{file = "platformdirs-4.3.1.tar.gz", hash = "sha256:63b79589009fa8159973601dd4563143396b35c5f93a58b36f9049ff046949b1"},
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
type = ["mypy (>=1.8)"]
docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.11.2)"]
[[package]]
name = "pluggy"
@ -629,13 +788,13 @@ test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark",
[[package]]
name = "pytest"
version = "8.3.2"
version = "8.3.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
]
[package.dependencies]
@ -647,6 +806,31 @@ pluggy = ">=1.5,<2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
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]]
name = "pyyaml"
version = "6.0.2"
@ -711,92 +895,128 @@ files = [
[[package]]
name = "regex"
version = "2024.7.24"
version = "2024.11.6"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.8"
files = [
{file = "regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce"},
{file = "regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024"},
{file = "regex-2024.7.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd"},
{file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53"},
{file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca"},
{file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59"},
{file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41"},
{file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5"},
{file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46"},
{file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f"},
{file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7"},
{file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe"},
{file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce"},
{file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa"},
{file = "regex-2024.7.24-cp310-cp310-win32.whl", hash = "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66"},
{file = "regex-2024.7.24-cp310-cp310-win_amd64.whl", hash = "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e"},
{file = "regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281"},
{file = "regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b"},
{file = "regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a"},
{file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73"},
{file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2"},
{file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e"},
{file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51"},
{file = "regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364"},
{file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee"},
{file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c"},
{file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce"},
{file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1"},
{file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e"},
{file = "regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c"},
{file = "regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52"},
{file = "regex-2024.7.24-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86"},
{file = "regex-2024.7.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad"},
{file = "regex-2024.7.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9"},
{file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289"},
{file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9"},
{file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c"},
{file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440"},
{file = "regex-2024.7.24-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610"},
{file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5"},
{file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799"},
{file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05"},
{file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94"},
{file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38"},
{file = "regex-2024.7.24-cp312-cp312-win32.whl", hash = "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc"},
{file = "regex-2024.7.24-cp312-cp312-win_amd64.whl", hash = "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908"},
{file = "regex-2024.7.24-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:66b4c0731a5c81921e938dcf1a88e978264e26e6ac4ec96a4d21ae0354581ae0"},
{file = "regex-2024.7.24-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:88ecc3afd7e776967fa16c80f974cb79399ee8dc6c96423321d6f7d4b881c92b"},
{file = "regex-2024.7.24-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64bd50cf16bcc54b274e20235bf8edbb64184a30e1e53873ff8d444e7ac656b2"},
{file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb462f0e346fcf41a901a126b50f8781e9a474d3927930f3490f38a6e73b6950"},
{file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a82465ebbc9b1c5c50738536fdfa7cab639a261a99b469c9d4c7dcbb2b3f1e57"},
{file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68a8f8c046c6466ac61a36b65bb2395c74451df2ffb8458492ef49900efed293"},
{file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac8e84fff5d27420f3c1e879ce9929108e873667ec87e0c8eeb413a5311adfe"},
{file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba2537ef2163db9e6ccdbeb6f6424282ae4dea43177402152c67ef869cf3978b"},
{file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:43affe33137fcd679bdae93fb25924979517e011f9dea99163f80b82eadc7e53"},
{file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c9bb87fdf2ab2370f21e4d5636e5317775e5d51ff32ebff2cf389f71b9b13750"},
{file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:945352286a541406f99b2655c973852da7911b3f4264e010218bbc1cc73168f2"},
{file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:8bc593dcce679206b60a538c302d03c29b18e3d862609317cb560e18b66d10cf"},
{file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3f3b6ca8eae6d6c75a6cff525c8530c60e909a71a15e1b731723233331de4169"},
{file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c51edc3541e11fbe83f0c4d9412ef6c79f664a3745fab261457e84465ec9d5a8"},
{file = "regex-2024.7.24-cp38-cp38-win32.whl", hash = "sha256:d0a07763776188b4db4c9c7fb1b8c494049f84659bb387b71c73bbc07f189e96"},
{file = "regex-2024.7.24-cp38-cp38-win_amd64.whl", hash = "sha256:8fd5afd101dcf86a270d254364e0e8dddedebe6bd1ab9d5f732f274fa00499a5"},
{file = "regex-2024.7.24-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0ffe3f9d430cd37d8fa5632ff6fb36d5b24818c5c986893063b4e5bdb84cdf24"},
{file = "regex-2024.7.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25419b70ba00a16abc90ee5fce061228206173231f004437730b67ac77323f0d"},
{file = "regex-2024.7.24-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33e2614a7ce627f0cdf2ad104797d1f68342d967de3695678c0cb84f530709f8"},
{file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33a0021893ede5969876052796165bab6006559ab845fd7b515a30abdd990dc"},
{file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04ce29e2c5fedf296b1a1b0acc1724ba93a36fb14031f3abfb7abda2806c1535"},
{file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b16582783f44fbca6fcf46f61347340c787d7530d88b4d590a397a47583f31dd"},
{file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:836d3cc225b3e8a943d0b02633fb2f28a66e281290302a79df0e1eaa984ff7c1"},
{file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438d9f0f4bc64e8dea78274caa5af971ceff0f8771e1a2333620969936ba10be"},
{file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:973335b1624859cb0e52f96062a28aa18f3a5fc77a96e4a3d6d76e29811a0e6e"},
{file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c5e69fd3eb0b409432b537fe3c6f44ac089c458ab6b78dcec14478422879ec5f"},
{file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3"},
{file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2757ace61bc4061b69af19e4689fa4416e1a04840f33b441034202b5cd02d4"},
{file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:44fc61b99035fd9b3b9453f1713234e5a7c92a04f3577252b45feefe1b327759"},
{file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:84c312cdf839e8b579f504afcd7b65f35d60b6285d892b19adea16355e8343c9"},
{file = "regex-2024.7.24-cp39-cp39-win32.whl", hash = "sha256:ca5b2028c2f7af4e13fb9fc29b28d0ce767c38c7facdf64f6c2cd040413055f1"},
{file = "regex-2024.7.24-cp39-cp39-win_amd64.whl", hash = "sha256:7c479f5ae937ec9985ecaf42e2e10631551d909f203e31308c12d703922742f9"},
{file = "regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506"},
{file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"},
{file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"},
{file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"},
{file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"},
{file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"},
{file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"},
{file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"},
{file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"},
{file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"},
{file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"},
{file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"},
{file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"},
{file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"},
{file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"},
{file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"},
{file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"},
{file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"},
{file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"},
{file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"},
{file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"},
{file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"},
{file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"},
{file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"},
{file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"},
{file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"},
{file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"},
{file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"},
{file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"},
{file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"},
{file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"},
{file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"},
{file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"},
{file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"},
{file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"},
{file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"},
{file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"},
{file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"},
{file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"},
{file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"},
{file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"},
{file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"},
{file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"},
{file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"},
{file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"},
{file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"},
{file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"},
{file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"},
{file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"},
{file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"},
{file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"},
{file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"},
{file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"},
{file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"},
{file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"},
{file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"},
{file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"},
{file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"},
{file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"},
{file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"},
{file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"},
{file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"},
{file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"},
{file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"},
{file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"},
{file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"},
{file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"},
{file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"},
{file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"},
{file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"},
{file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"},
{file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"},
{file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"},
{file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"},
{file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"},
{file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"},
{file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"},
{file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"},
{file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"},
{file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"},
{file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"},
{file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"},
{file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"},
{file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"},
{file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"},
{file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"},
{file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"},
{file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"},
{file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"},
{file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"},
{file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"},
{file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"},
{file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"},
{file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"},
{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]]
name = "six"
version = "1.16.0"
@ -808,17 +1028,6 @@ files = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]]
name = "soupsieve"
version = "2.6"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.8"
files = [
{file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"},
{file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"},
]
[[package]]
name = "sqlparse"
version = "0.5.1"
@ -847,13 +1056,13 @@ files = [
[[package]]
name = "tqdm"
version = "4.66.5"
version = "4.67.0"
description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.7"
files = [
{file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"},
{file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"},
{file = "tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be"},
{file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"},
]
[package.dependencies]
@ -861,6 +1070,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"]
discord = ["requests"]
notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
@ -878,15 +1088,32 @@ files = [
[[package]]
name = "tzdata"
version = "2024.1"
version = "2024.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
files = [
{file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"},
{file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
{file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
{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]]
name = "uvicorn"
version = "0.30.6"
@ -907,13 +1134,13 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
[[package]]
name = "virtualenv"
version = "20.26.4"
version = "20.27.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"},
{file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"},
{file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"},
{file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"},
]
[package.dependencies]
@ -928,4 +1155,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "508565632b7642265ad85a540a98a9ff3fa1e936eb26c0b7efae0707429b4c17"
content-hash = "b5bb46a6591964aec145637cd9a412a681f2cc5e7e4fdd6fd9ecb0fe8724b8e3"

View File

@ -28,9 +28,12 @@ graphene-django = "^3.2.0"
django-htmx = "^1.18.0"
django-template-partials = "^24.2"
markdown = "^3.6"
django-cotton = "^1.2.1"
django-cotton = "^0.9.34"
django-q2 = "^1.7.4"
croniter = "^5.0.1"
requests = "^2.32.3"
pyyaml = "^6.0.2"
[tool.isort]
profile = "black"

View File

@ -42,8 +42,18 @@ INSTALLED_APPS = [
"graphene_django",
"django_htmx",
"django_cotton",
"django_q",
]
Q_CLUSTER = {
"name": "DjangoQ",
"workers": 4,
"recycle": 500,
"timeout": 60,
"retry": 120,
"orm": "default",
}
GRAPHENE = {"SCHEMA": "games.schema.schema"}
if DEBUG:
@ -84,6 +94,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"games.views.general.model_counts",
"games.views.general.global_current_year",
],
"builtins": [
"template_partials.templatetags.partials",