Introduce game status, playevents
This commit is contained in:
parent
d892659132
commit
89de85c00d
@ -1,10 +1,14 @@
|
||||
import operator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from functools import reduce
|
||||
from functools import reduce, wraps
|
||||
from typing import Any, Callable, Generator, Literal, TypeVar
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
||||
"""
|
||||
@ -40,7 +44,7 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
|
||||
|
||||
def truncate_(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
|
||||
return (
|
||||
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}")
|
||||
(f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
|
||||
if len(input_string) > length
|
||||
else input_string
|
||||
)
|
||||
@ -54,12 +58,12 @@ def truncate(
|
||||
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[: 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}"
|
||||
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
|
||||
)
|
||||
|
||||
|
||||
@ -128,3 +132,36 @@ def build_dynamic_filter(
|
||||
processed_filters,
|
||||
Q(),
|
||||
)
|
||||
|
||||
|
||||
def redirect_to(default_view: str, *default_args):
|
||||
"""
|
||||
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
|
||||
|
||||
:param default_view: The name of the default view to redirect to if 'next' is missing.
|
||||
:param default_args: Any arguments required for the default view.
|
||||
"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped_view(request: HttpRequest, *args, **kwargs):
|
||||
next_url = request.GET.get("next")
|
||||
if not next_url:
|
||||
from django.urls import (
|
||||
reverse, # Import inside function to avoid circular imports
|
||||
)
|
||||
|
||||
next_url = reverse(default_view, args=default_args)
|
||||
|
||||
response = view_func(
|
||||
request, *args, **kwargs
|
||||
) # Execute the original view logic
|
||||
return redirect(next_url)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def add_next_param_to_url(url: str, nexturl: str) -> str:
|
||||
return f"{url}?{urlencode({'next': nexturl})}"
|
||||
|
80
games/api.py
Normal file
80
games/api.py
Normal file
@ -0,0 +1,80 @@
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now as django_timezone_now
|
||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
|
||||
|
||||
from games.models import PlayEvent
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
|
||||
class PlayEventIn(Schema):
|
||||
game_id: int
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
days_to_finish: int | None = None
|
||||
|
||||
|
||||
class AutoPlayEventIn(ModelSchema):
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = ["game", "started", "ended", "note"]
|
||||
|
||||
|
||||
class UpdatePlayEventIn(Schema):
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class PlayEventOut(Schema):
|
||||
id: int
|
||||
game: str = Field(..., alias="game.name")
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
days_to_finish: int | None = None
|
||||
note: str = ""
|
||||
updated_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@playevent_router.get("/", response=List[PlayEventOut])
|
||||
def list_playevents(request):
|
||||
return PlayEvent.objects.all()
|
||||
|
||||
|
||||
@playevent_router.post("/", response={201: PlayEventOut})
|
||||
def create_playevent(request, payload: PlayEventIn):
|
||||
playevent = PlayEvent.objects.create(**payload.dict())
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
|
||||
def get_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
|
||||
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
for attr, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(playevent, attr, value)
|
||||
playevent.save()
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.delete("/{playevent_id}", response={204: None})
|
||||
def delete_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
playevent.delete()
|
||||
return 204, None
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
@ -1,9 +1,10 @@
|
||||
from datetime import timedelta
|
||||
# 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
|
||||
|
||||
# from django.utils.timezone import now
|
||||
|
||||
|
||||
class GamesConfig(AppConfig):
|
||||
@ -17,26 +18,26 @@ class GamesConfig(AppConfig):
|
||||
|
||||
|
||||
def schedule_tasks(sender, **kwargs):
|
||||
from django_q.models import Schedule
|
||||
from django_q.tasks import schedule
|
||||
# from django_q.models import Schedule
|
||||
# from django_q.tasks import schedule
|
||||
|
||||
if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
schedule(
|
||||
"games.tasks.convert_prices",
|
||||
name="Update converted prices",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
catchup=False,
|
||||
)
|
||||
# if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
# schedule(
|
||||
# "games.tasks.convert_prices",
|
||||
# name="Update converted prices",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
schedule(
|
||||
"games.tasks.calculate_price_per_game",
|
||||
name="Update price per game",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
catchup=False,
|
||||
)
|
||||
# if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
# schedule(
|
||||
# "games.tasks.calculate_price_per_game",
|
||||
# name="Update price per game",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
from games.models import ExchangeRate
|
||||
|
||||
|
@ -2,7 +2,7 @@ from django import forms
|
||||
from django.urls import reverse
|
||||
|
||||
from common.utils import safe_getattr
|
||||
from games.models import Device, Game, Platform, Purchase, Session
|
||||
from games.models import Device, Game, Platform, PlayEvent, Purchase, Session
|
||||
|
||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||
custom_datetime_widget = forms.DateTimeInput(
|
||||
@ -103,8 +103,6 @@ class PurchaseForm(forms.ModelForm):
|
||||
widgets = {
|
||||
"date_purchased": custom_date_widget,
|
||||
"date_refunded": custom_date_widget,
|
||||
"date_finished": custom_date_widget,
|
||||
"date_dropped": custom_date_widget,
|
||||
}
|
||||
model = Purchase
|
||||
fields = [
|
||||
@ -112,8 +110,6 @@ class PurchaseForm(forms.ModelForm):
|
||||
"platform",
|
||||
"date_purchased",
|
||||
"date_refunded",
|
||||
"date_finished",
|
||||
"date_dropped",
|
||||
"infinite",
|
||||
"price",
|
||||
"price_currency",
|
||||
@ -171,6 +167,7 @@ class GameForm(forms.ModelForm):
|
||||
"name",
|
||||
"sort_name",
|
||||
"platform",
|
||||
"original_year_released",
|
||||
"year_released",
|
||||
"status",
|
||||
"mastered",
|
||||
@ -195,3 +192,23 @@ class DeviceForm(forms.ModelForm):
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = [
|
||||
"game",
|
||||
"started",
|
||||
"ended",
|
||||
"note",
|
||||
]
|
||||
widgets = {
|
||||
"started": custom_date_widget,
|
||||
"ended": custom_date_widget,
|
||||
}
|
||||
|
@ -0,0 +1,190 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-19 13:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Min
|
||||
|
||||
|
||||
def copy_year_released(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.update(original_year_released=F("year_released"))
|
||||
|
||||
|
||||
def set_abandoned_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game = apps.get_model("games", "Game")
|
||||
PlayEvent = apps.get_model("games", "PlayEvent")
|
||||
|
||||
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
|
||||
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
|
||||
|
||||
finished = Game.objects.filter(purchases__date_finished__isnull=False)
|
||||
|
||||
for game in finished:
|
||||
for purchase in game.purchases.all():
|
||||
first_session = game.sessions.filter(
|
||||
timestamp_start__gte=purchase.date_purchased
|
||||
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
|
||||
first_session_date = first_session.date() if first_session else None
|
||||
if purchase.date_finished:
|
||||
play_event = PlayEvent(
|
||||
game=game,
|
||||
started=first_session_date
|
||||
if first_session_date
|
||||
else purchase.date_purchased,
|
||||
ended=purchase.date_finished,
|
||||
)
|
||||
play_event.save()
|
||||
|
||||
|
||||
def create_game_status_changes(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
GameStatusChange = apps.get_model("games", "GameStatusChange")
|
||||
|
||||
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
|
||||
for game in Game.objects.filter(sessions__isnull=False).distinct():
|
||||
if game.sessions.exists():
|
||||
earliest_session = game.sessions.earliest()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="u",
|
||||
new_status="p",
|
||||
timestamp=earliest_session.timestamp_start,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_dropped,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_refunded,
|
||||
)
|
||||
|
||||
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
|
||||
# consider only the first playevent
|
||||
for game in Game.objects.filter(playevents__isnull=False):
|
||||
first_playevent = game.playevents.first()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="f",
|
||||
timestamp=first_playevent.ended,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0007_game_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="original_year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.RunPython(copy_year_released),
|
||||
migrations.CreateModel(
|
||||
name="GameStatusChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"old_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"new_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
("timestamp", models.DateTimeField(null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="status_changes",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("started", models.DateField(blank=True, null=True)),
|
||||
("ended", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"days_to_finish",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.RawSQL(
|
||||
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
),
|
||||
),
|
||||
("note", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="playevents",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(set_abandoned_status),
|
||||
migrations.RunPython(create_game_status_changes),
|
||||
]
|
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-20 11:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_dropped',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_finished',
|
||||
),
|
||||
]
|
17
games/migrations/0010_remove_purchase_price_per_game.py
Normal file
17
games/migrations/0010_remove_purchase_price_per_game.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0009_remove_purchase_date_dropped_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
),
|
||||
]
|
20
games/migrations/0011_purchase_price_per_game.py
Normal file
20
games/migrations/0011_purchase_price_per_game.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0010_remove_purchase_price_per_game'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
|
||||
),
|
||||
]
|
155
games/models.py
155
games/models.py
@ -1,13 +1,20 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.fields.generated import GeneratedField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.template.defaultfilters import floatformat, pluralize, slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from common.time import format_duration
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
class Meta:
|
||||
@ -16,6 +23,7 @@ class Game(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
sort_name = models.CharField(max_length=255, blank=True, default="")
|
||||
year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
original_year_released = models.IntegerField(null=True, blank=True, default=None)
|
||||
wikidata = models.CharField(max_length=50, blank=True, default="")
|
||||
platform = models.ForeignKey(
|
||||
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
||||
@ -55,6 +63,21 @@ class Game(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def finished(self):
|
||||
return self.status == self.Status.FINISHED
|
||||
|
||||
def abandoned(self):
|
||||
return self.status == self.Status.ABANDONED
|
||||
|
||||
def retired(self):
|
||||
return self.status == self.Status.RETIRED
|
||||
|
||||
def played(self):
|
||||
return self.status == self.Status.PLAYED
|
||||
|
||||
def unplayed(self):
|
||||
return self.status == self.Status.UNPLAYED
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.platform is None:
|
||||
self.platform = get_sentinel_platform()
|
||||
@ -89,9 +112,6 @@ class PurchaseQueryset(models.QuerySet):
|
||||
def not_refunded(self):
|
||||
return self.filter(date_refunded__isnull=True)
|
||||
|
||||
def finished(self):
|
||||
return self.filter(date_finished__isnull=False)
|
||||
|
||||
def games_only(self):
|
||||
return self.filter(type=Purchase.GAME)
|
||||
|
||||
@ -135,14 +155,22 @@ class Purchase(models.Model):
|
||||
)
|
||||
date_purchased = models.DateField()
|
||||
date_refunded = models.DateField(blank=True, null=True)
|
||||
date_finished = models.DateField(blank=True, null=True)
|
||||
date_dropped = models.DateField(blank=True, null=True)
|
||||
# move date_finished to PlayEvent model's Finished field
|
||||
# also set Game's model Status field to Finished
|
||||
# date_finished = models.DateField(blank=True, null=True)
|
||||
# move date_dropped to Game model's field Status (Abandoned)
|
||||
# date_dropped = models.DateField(blank=True, null=True)
|
||||
infinite = models.BooleanField(default=False)
|
||||
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, blank=True, default="")
|
||||
price_per_game = models.FloatField(null=True)
|
||||
price_per_game = GeneratedField(
|
||||
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
||||
output_field=models.FloatField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
)
|
||||
num_purchases = models.IntegerField(default=0)
|
||||
ownership_type = models.CharField(
|
||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||
@ -198,6 +226,12 @@ class Purchase(models.Model):
|
||||
def is_game(self):
|
||||
return self.type == self.GAME
|
||||
|
||||
def price_or_currency_differ_from(self, purchase_to_compare):
|
||||
return (
|
||||
self.price != purchase_to_compare.price
|
||||
or self.price_currency != purchase_to_compare.price_currency
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.type != Purchase.GAME and not self.related_purchase:
|
||||
raise ValidationError(
|
||||
@ -207,12 +241,15 @@ class Purchase(models.Model):
|
||||
# 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 = ""
|
||||
if existing_purchase.price_or_currency_differ_from(self):
|
||||
from games.tasks import currency_to
|
||||
|
||||
exchange_rate = get_or_create_rate(
|
||||
self.price_currency, currency_to, self.date_purchased.year
|
||||
)
|
||||
if exchange_rate:
|
||||
self.converted_price = floatformat(self.price * exchange_rate, 0)
|
||||
self.converted_currency = currency_to
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@ -351,3 +388,97 @@ class ExchangeRate(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
|
||||
|
||||
|
||||
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
|
||||
exchange_rate = None
|
||||
result = ExchangeRate.objects.filter(
|
||||
currency_from=currency_from, currency_to=currency_to, year=year
|
||||
)
|
||||
if result:
|
||||
exchange_rate = result[0].rate
|
||||
else:
|
||||
try:
|
||||
# this API endpoint only accepts lowercase currency string
|
||||
response = requests.get(
|
||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
currency_from_data = data.get(currency_from.lower())
|
||||
rate = currency_from_data.get(currency_to.lower())
|
||||
|
||||
if rate:
|
||||
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
||||
exchange_rate = ExchangeRate.objects.create(
|
||||
currency_from=currency_from,
|
||||
currency_to=currency_to,
|
||||
year=year,
|
||||
rate=floatformat(rate, 2),
|
||||
)
|
||||
exchange_rate = exchange_rate.rate
|
||||
else:
|
||||
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||
except requests.RequestException as e:
|
||||
logger.info(
|
||||
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||
)
|
||||
return exchange_rate
|
||||
|
||||
|
||||
class PlayEvent(models.Model):
|
||||
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
|
||||
started = models.DateField(null=True, blank=True)
|
||||
ended = models.DateField(null=True, blank=True)
|
||||
days_to_finish = GeneratedField(
|
||||
# special cases:
|
||||
# missing ended, started, or both = 0
|
||||
# same day = 1 day to finish
|
||||
expression=RawSQL(
|
||||
"""
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN date(ended) = date(started) THEN 1
|
||||
ELSE julianday(ended) - julianday(started)
|
||||
END, 0
|
||||
)
|
||||
""",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
blank=True,
|
||||
)
|
||||
note = models.CharField(max_length=255, blank=True, default="")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
# class PlayMarker(models.Model):
|
||||
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
|
||||
# played_since = models.DurationField()
|
||||
# played_total = models.DurationField()
|
||||
# note = models.CharField(max_length=255)
|
||||
|
||||
|
||||
class GameStatusChange(models.Model):
|
||||
"""
|
||||
Tracks changes to the status of a Game.
|
||||
"""
|
||||
|
||||
game = models.ForeignKey(
|
||||
Game, on_delete=models.CASCADE, related_name="status_changes"
|
||||
)
|
||||
old_status = models.CharField(
|
||||
max_length=1, choices=Game.Status.choices, blank=True, null=True
|
||||
)
|
||||
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
|
||||
timestamp = models.DateTimeField(null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
|
||||
|
||||
class Meta:
|
||||
ordering = ["-timestamp"]
|
||||
|
@ -1,8 +1,8 @@
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.signals import m2m_changed, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from games.models import Purchase
|
||||
from games.models import Game, GameStatusChange, Purchase
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Purchase.games.through)
|
||||
@ -10,3 +10,31 @@ def update_num_purchases(sender, instance, **kwargs):
|
||||
instance.num_purchases = instance.games.count()
|
||||
instance.updated_at = now()
|
||||
instance.save(update_fields=["num_purchases"])
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Game)
|
||||
def game_status_changed(sender, instance, **kwargs):
|
||||
"""
|
||||
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
|
||||
"""
|
||||
try:
|
||||
old_instance = sender.objects.get(pk=instance.pk)
|
||||
old_status = old_instance.status
|
||||
print("Got old instance")
|
||||
except sender.DoesNotExist:
|
||||
# Handle the case where the instance was deleted before the signal was sent
|
||||
print("Instance does not exist")
|
||||
return
|
||||
|
||||
if old_status != instance.status:
|
||||
print("Status changed")
|
||||
GameStatusChange.objects.create(
|
||||
game=instance,
|
||||
old_status=old_status,
|
||||
new_status=instance.status,
|
||||
timestamp=instance.updated_at,
|
||||
)
|
||||
else:
|
||||
print("Status not changed")
|
||||
print(f"{old_instance.status}")
|
||||
print(f"{instance.status}")
|
||||
|
@ -1303,6 +1303,10 @@ input:checked + .toggle-bg {
|
||||
left: -0.75rem;
|
||||
}
|
||||
|
||||
.-left-\[1px\] {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.bottom-0 {
|
||||
bottom: 0px;
|
||||
}
|
||||
@ -1347,6 +1351,10 @@ input:checked + .toggle-bg {
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
||||
.top-\[100\%\] {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
@ -1552,6 +1560,10 @@ input:checked + .toggle-bg {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
@ -1576,6 +1588,10 @@ input:checked + .toggle-bg {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.w-auto {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
@ -1744,6 +1760,10 @@ input:checked + .toggle-bg {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.gap-y-4 {
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
.-space-x-px > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(-1px * var(--tw-space-x-reverse));
|
||||
@ -1819,6 +1839,11 @@ input:checked + .toggle-bg {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.rounded-b-md {
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-e-lg {
|
||||
border-start-end-radius: 0.5rem;
|
||||
border-end-end-radius: 0.5rem;
|
||||
@ -1839,6 +1864,14 @@ input:checked + .toggle-bg {
|
||||
border-end-start-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-tl-none {
|
||||
border-top-left-radius: 0px;
|
||||
}
|
||||
|
||||
.rounded-tr-md {
|
||||
border-top-right-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
@ -1847,6 +1880,18 @@ input:checked + .toggle-bg {
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.border-e {
|
||||
border-inline-end-width: 1px;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-blue-600 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(28 100 242 / var(--tw-border-opacity));
|
||||
@ -1922,6 +1967,10 @@ input:checked + .toggle-bg {
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-800\/20 {
|
||||
background-color: rgb(31 41 55 / 0.2);
|
||||
}
|
||||
|
||||
.bg-gray-900\/50 {
|
||||
background-color: rgb(17 24 39 / 0.5);
|
||||
}
|
||||
@ -2087,6 +2136,10 @@ input:checked + .toggle-bg {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
@ -2311,6 +2364,12 @@ input:checked + .toggle-bg {
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
|
||||
.backdrop-blur-lg {
|
||||
--tw-backdrop-blur: blur(16px);
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||
}
|
||||
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
|
@ -21,11 +21,6 @@ function setupElementHandlers() {
|
||||
"#id_name",
|
||||
"#id_related_purchase",
|
||||
]);
|
||||
disableElementsWhenValueNotEqual(
|
||||
"#id_type",
|
||||
["game", "dlc"],
|
||||
["#id_date_finished"]
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
|
@ -12,6 +12,10 @@
|
||||
{% django_htmx_script %}
|
||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
|
||||
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
|
||||
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
|
6
games/templates/list_playevents.html
Normal file
6
games/templates/list_playevents.html
Normal file
@ -0,0 +1,6 @@
|
||||
<c-layouts.base>
|
||||
{% load static %}
|
||||
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
|
||||
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
||||
</div>
|
||||
</c-layouts.base>
|
@ -106,6 +106,10 @@
|
||||
<a href="{% url 'list_platforms' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'list_playevents' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'list_purchases' %}"
|
||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
|
||||
|
@ -196,7 +196,7 @@
|
||||
<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.game_name }}</td>
|
||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -209,14 +209,14 @@
|
||||
<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>
|
||||
{% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
|
||||
</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>
|
||||
{% comment %} <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% endcomment %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@ -228,14 +228,14 @@
|
||||
<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>
|
||||
{% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
|
||||
</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>
|
||||
{% comment %} <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> {% endcomment %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
@ -52,17 +52,66 @@
|
||||
{{ playrange }}
|
||||
</c-popover>
|
||||
</div>
|
||||
<div class="mb-6 text-slate-400">
|
||||
<div class="flex flex-col mb-6 text-slate-400 gap-y-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase font-bold text-slate-300">Status</span>
|
||||
<c-gamestatus :status="game.status">
|
||||
{{ game.get_status_display }}
|
||||
</c-gamestatus>
|
||||
{% if game.mastered %}👑{% endif %}
|
||||
<span class="uppercase">Original year</span>
|
||||
<span class="text-slate-300">{{ game.original_year_released }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase font-bold text-slate-300">Platform</span>
|
||||
<span>{{ game.platform }}</span>
|
||||
<span class="uppercase">Status</span>
|
||||
<c-gamestatus :status="game.status" class="text-slate-300">
|
||||
{{ game.get_status_display }}
|
||||
</c-gamestatus>
|
||||
{% if game.mastered %}👑{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{ open: false }"
|
||||
>
|
||||
<span class="uppercase">Played</span>
|
||||
<div class="inline-flex rounded-md shadow-xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
|
||||
<a href="{% url 'add_playevent' %}">
|
||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||
<span x-text="played"></span> times
|
||||
</button>
|
||||
</a>
|
||||
<button type="button" x-on:click="open = !open" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle">
|
||||
<svg class="text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div
|
||||
class="absolute top-[100%] -left-[1px] w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
||||
x-show="open"
|
||||
>
|
||||
<ul
|
||||
class=""
|
||||
>
|
||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||
<a href="{% url 'add_playevent_for_game' game.id %}">Add playthrough...</a>
|
||||
</li>
|
||||
<li
|
||||
x-on:click="createPlayEvent"
|
||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
||||
>
|
||||
Played times +1
|
||||
</li>
|
||||
<script>
|
||||
function createPlayEvent() {
|
||||
this.played++;
|
||||
fetch('{% url 'api-1.0.0:create_playevent' %}', { method: 'POST', headers: { 'X-CSRFToken': '{{ csrf_token }}' }, body: '{"game_id": {{ game.id }}}'})
|
||||
}
|
||||
</script>
|
||||
</ul>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="uppercase">Platform</span>
|
||||
<span class="text-slate-300">{{ game.platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
|
||||
@ -96,6 +145,24 @@
|
||||
No sessions yet.
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- list all playevents -->
|
||||
<div class="mb-6">
|
||||
<c-h1 :badge="playevent_count">Play Events</c-h1>
|
||||
{% if playevent_count %}
|
||||
<c-simple-table :rows=playevent_data.rows :columns=playevent_data.columns />
|
||||
{% else %}
|
||||
No play events yet.
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<c-h1 :badge="statuschange_count">History</c-h1>
|
||||
<ul class="list-disc list-inside">
|
||||
{% for change in statuschanges %}
|
||||
<li class="text-slate-500">
|
||||
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status" class="text-white">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status" class="text-white">{{ change.get_new_status_display }}</c-gamestatus></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function getSessionCount() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from games.views import device, game, general, platform, purchase, session
|
||||
from games.api import api
|
||||
from games.views import device, game, general, platform, playevent, purchase, session
|
||||
|
||||
urlpatterns = [
|
||||
path("", general.index, name="index"),
|
||||
@ -25,6 +26,23 @@ urlpatterns = [
|
||||
name="delete_platform",
|
||||
),
|
||||
path("platform/list", platform.list_platforms, name="list_platforms"),
|
||||
path("playevent/list", playevent.list_playevents, name="list_playevents"),
|
||||
path("playevent/add", playevent.add_playevent, name="add_playevent"),
|
||||
path(
|
||||
"playevent/add/for-game/<int:game_id>",
|
||||
playevent.add_playevent,
|
||||
name="add_playevent_for_game",
|
||||
),
|
||||
path(
|
||||
"playevent/edit/<int:playevent_id>",
|
||||
playevent.edit_playevent,
|
||||
name="edit_playevent",
|
||||
),
|
||||
path(
|
||||
"playevent/delete/<int:playevent_id>",
|
||||
playevent.delete_playevent,
|
||||
name="delete_playevent",
|
||||
),
|
||||
path("purchase/add", purchase.add_purchase, name="add_purchase"),
|
||||
path(
|
||||
"purchase/add/for-game/<int:game_id>",
|
||||
@ -115,4 +133,5 @@ urlpatterns = [
|
||||
general.stats,
|
||||
name="stats_by_year",
|
||||
),
|
||||
path("api/", api.urls),
|
||||
]
|
||||
|
@ -32,6 +32,7 @@ from common.utils import build_dynamic_filter, safe_division, truncate
|
||||
from games.forms import GameForm
|
||||
from games.models import Game, Purchase
|
||||
from games.views.general import use_custom_redirect
|
||||
from games.views.playevent import create_playevent_tabledata
|
||||
|
||||
|
||||
@login_required
|
||||
@ -351,7 +352,34 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
],
|
||||
}
|
||||
|
||||
playevents = game.playevents.all()
|
||||
playevent_count = playevents.count()
|
||||
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
|
||||
|
||||
statuschanges = game.status_changes.all()
|
||||
statuschange_count = statuschanges.count()
|
||||
statuschange_data = {
|
||||
"columns": [
|
||||
"Old Status",
|
||||
"New Status",
|
||||
"Timestamp",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
statuschange.get_old_status_display()
|
||||
if statuschange.old_status
|
||||
else "-",
|
||||
statuschange.get_new_status_display(),
|
||||
local_strftime(statuschange.timestamp, dateformat),
|
||||
]
|
||||
for statuschange in statuschanges
|
||||
],
|
||||
}
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"statuschange_data": statuschange_data,
|
||||
"statuschange_count": statuschange_count,
|
||||
"statuschanges": statuschanges,
|
||||
"game": game,
|
||||
"playrange": playrange,
|
||||
"purchase_count": game.purchases.count(),
|
||||
@ -366,6 +394,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
"title": f"Game Overview - {game.name}",
|
||||
"hours_sum": total_hours,
|
||||
"purchase_data": purchase_data,
|
||||
"playevent_data": playevent_data,
|
||||
"playevent_count": playevent_count,
|
||||
"session_data": session_data,
|
||||
"session_page_obj": session_page_obj,
|
||||
"session_elided_page_range": (
|
||||
|
@ -305,27 +305,39 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
|
||||
this_year_purchases_with_currency = this_year_purchases.prefetch_related("games")
|
||||
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
||||
date_refunded=None
|
||||
).exclude(ownership_type=Purchase.DEMO)
|
||||
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
|
||||
this_year_purchases = Purchase.objects.filter(
|
||||
date_purchased__year=year
|
||||
).prefetch_related("games")
|
||||
# purchased this year
|
||||
# not refunded
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(
|
||||
date_refunded=None, date_purchased__year=year
|
||||
)
|
||||
|
||||
# purchased this year
|
||||
# not refunded
|
||||
# not finished
|
||||
# not infinite
|
||||
# only Game and DLC
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(date_finished__isnull=True)
|
||||
this_year_purchases_without_refunded.exclude(
|
||||
games__in=Game.objects.filter(status="f")
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
) # do not count battle passes etc.
|
||||
)
|
||||
|
||||
# not finished
|
||||
this_year_purchases_unfinished = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
date_dropped__isnull=True
|
||||
this_year_purchases_unfinished_dropped_nondropped.exclude(
|
||||
games__status__in="ura"
|
||||
)
|
||||
)
|
||||
# abandoned
|
||||
# retired
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
date_dropped__isnull=False
|
||||
this_year_purchases_unfinished_dropped_nondropped.exclude(
|
||||
games__in=Game.objects.filter(status="ar")
|
||||
)
|
||||
)
|
||||
|
||||
@ -341,15 +353,17 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
* 100
|
||||
)
|
||||
|
||||
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
|
||||
purchases_finished_this_year = Purchase.objects.filter(
|
||||
games__playevents__ended__year=2025
|
||||
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
||||
purchases_finished_this_year_released_this_year = (
|
||||
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
||||
"date_finished"
|
||||
"games__playevents__ended"
|
||||
)
|
||||
)
|
||||
purchased_this_year_finished_this_year = (
|
||||
this_year_purchases_without_refunded.filter(date_finished__year=year)
|
||||
).order_by("date_finished")
|
||||
this_year_purchases_without_refunded.filter(games__playevents__ended__year=year)
|
||||
).order_by("games__playevents__ended")
|
||||
|
||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||
total_spent=Sum(F("converted_price"))
|
||||
@ -395,8 +409,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
||||
|
||||
backlog_decrease_count = (
|
||||
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||
.intersection(purchases_finished_this_year)
|
||||
Purchase.objects.filter(date_purchased__year__lt=2025)
|
||||
.filter(games__status="f")
|
||||
.filter(games__playevents__ended__year=2025)
|
||||
.count()
|
||||
)
|
||||
|
||||
@ -412,7 +427,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases_with_currency.count()
|
||||
all_purchased_this_year_count = this_year_purchases.count()
|
||||
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
|
||||
date_purchased__year=year
|
||||
)
|
||||
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
|
||||
|
||||
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
|
||||
@ -439,15 +457,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
),
|
||||
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
).order_by("games__playevents__ended"),
|
||||
"all_finished_this_year_count": purchases_finished_this_year.count(),
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
).order_by("games__playevents__ended"),
|
||||
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("date_finished"),
|
||||
).order_by("games__playevents__ended"),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||
@ -465,9 +483,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
),
|
||||
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
|
||||
"date_purchased"
|
||||
),
|
||||
"all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
|
||||
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||
"backlog_decrease_count": backlog_decrease_count,
|
||||
"longest_session_time": (
|
||||
|
147
games/views/playevent.py
Normal file
147
games/views/playevent.py
Normal file
@ -0,0 +1,147 @@
|
||||
import logging
|
||||
from typing import Any, Callable, TypedDict
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components import A, Button, Icon
|
||||
from common.time import dateformat, local_strftime
|
||||
from games.forms import PlayEventForm
|
||||
from games.models import Game, PlayEvent
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
|
||||
class TableData(TypedDict):
|
||||
header_action: Callable[..., Any]
|
||||
columns: list[str]
|
||||
rows: list[list[Any]]
|
||||
|
||||
|
||||
def create_playevent_tabledata(
|
||||
playevents: list[PlayEvent] | BaseManager[PlayEvent] | QuerySet[PlayEvent],
|
||||
exclude_columns: list[str] = [],
|
||||
request: HttpRequest | None = None,
|
||||
) -> TableData:
|
||||
column_list = [
|
||||
"Game",
|
||||
"Started",
|
||||
"Ended",
|
||||
"Days to finish",
|
||||
"Note",
|
||||
"Created",
|
||||
"Actions",
|
||||
]
|
||||
filtered_column_list = filter(
|
||||
lambda x: x not in exclude_columns,
|
||||
column_list,
|
||||
)
|
||||
excluded_column_indexes = [column_list.index(column) for column in exclude_columns]
|
||||
|
||||
row_list = [
|
||||
[
|
||||
playevent.game,
|
||||
playevent.started.strftime(dateformat) if playevent.started else "-",
|
||||
playevent.ended.strftime(dateformat) if playevent.ended else "-",
|
||||
playevent.days_to_finish if playevent.days_to_finish else "-",
|
||||
playevent.note,
|
||||
local_strftime(playevent.created_at, dateformat),
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
"buttons": [
|
||||
{
|
||||
"href": reverse("edit_playevent", args=[playevent.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("delete_playevent", args=[playevent.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
},
|
||||
),
|
||||
]
|
||||
for playevent in playevents
|
||||
]
|
||||
filtered_row_list = [
|
||||
[column for idx, column in enumerate(row) if idx not in excluded_column_indexes]
|
||||
for row in row_list
|
||||
]
|
||||
return {
|
||||
"header_action": A([], Button([], "Add play event"), url="add_playevent"),
|
||||
"columns": list(filtered_column_list),
|
||||
"rows": filtered_row_list,
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
playevents = PlayEvent.objects.order_by("-created_at")
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(playevents, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
playevents = page_obj.object_list
|
||||
context: dict[str, Any] = {
|
||||
"title": "Manage play events",
|
||||
"page_obj": page_obj or None,
|
||||
"elided_page_range": (
|
||||
page_obj.paginator.get_elided_page_range(
|
||||
page_number, on_each_side=1, on_ends=1
|
||||
)
|
||||
if page_obj
|
||||
else None
|
||||
),
|
||||
"data": create_playevent_tabledata(playevents, request=request),
|
||||
}
|
||||
return render(request, "list_playevents.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
initial: dict[str, Any] = {}
|
||||
if game_id:
|
||||
# coming from add_playevent_for_game url path
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
initial["game"] = game
|
||||
form = PlayEventForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
if not game_id:
|
||||
# coming from add_playevent url path
|
||||
game_id = form.instance.game.id
|
||||
return HttpResponseRedirect(reverse("view_game", args=[game_id]))
|
||||
|
||||
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
|
||||
|
||||
|
||||
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
context: dict[str, Any] = {}
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
form = PlayEventForm(request.POST or None, instance=playevent)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return HttpResponseRedirect(reverse("view_game", args=[playevent.game.id]))
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"title": "Edit Play Event",
|
||||
}
|
||||
return render(request, "add.html", context)
|
||||
|
||||
|
||||
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
playevent.delete()
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
@ -51,8 +51,6 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
"Infinite",
|
||||
"Purchased",
|
||||
"Refunded",
|
||||
"Finished",
|
||||
"Dropped",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
@ -68,39 +66,11 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
if purchase.date_refunded
|
||||
else "-"
|
||||
),
|
||||
(
|
||||
purchase.date_finished.strftime(dateformat)
|
||||
if purchase.date_finished
|
||||
else "-"
|
||||
),
|
||||
(
|
||||
purchase.date_dropped.strftime(dateformat)
|
||||
if purchase.date_dropped
|
||||
else "-"
|
||||
),
|
||||
purchase.created_at.strftime(dateformat),
|
||||
render_to_string(
|
||||
"cotton/button_group.html",
|
||||
{
|
||||
"buttons": [
|
||||
{
|
||||
"href": reverse(
|
||||
"finish_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("checkmark"),
|
||||
"title": "Mark as finished",
|
||||
}
|
||||
if not purchase.date_finished
|
||||
else {},
|
||||
{
|
||||
"href": reverse(
|
||||
"drop_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("eject"),
|
||||
"title": "Mark as dropped",
|
||||
}
|
||||
if not purchase.date_dropped
|
||||
else {},
|
||||
{
|
||||
"href": reverse(
|
||||
"refund_purchase", args=[purchase.pk]
|
||||
|
169
poetry.lock
generated
169
poetry.lock
generated
@ -1,5 +1,17 @@
|
||||
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
description = "Reusable constraint types to use with typing.Annotated"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
|
||||
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.8.1"
|
||||
@ -309,6 +321,27 @@ files = [
|
||||
asgiref = ">=3.6"
|
||||
django = ">=4.2"
|
||||
|
||||
[[package]]
|
||||
name = "django-ninja"
|
||||
version = "1.3.0"
|
||||
description = "Django Ninja - Fast Django REST framework"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django_ninja-1.3.0-py3-none-any.whl", hash = "sha256:f58096b6c767d1403dfd6c49743f82d780d7b9688d9302ecab316ac1fa6131bb"},
|
||||
{file = "django_ninja-1.3.0.tar.gz", hash = "sha256:5b320e2dc0f41a6032bfa7e1ebc33559ae1e911a426f0c6be6674a50b20819be"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Django = ">=3.1"
|
||||
pydantic = ">=2.0,<3.0.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit"]
|
||||
doc = ["markdown-include", "mkdocs", "mkdocs-material", "mkdocstrings"]
|
||||
test = ["django-stubs", "mypy (==1.7.1)", "psycopg2-binary", "pytest", "pytest-asyncio", "pytest-cov", "pytest-django", "ruff (==0.5.7)"]
|
||||
|
||||
[[package]]
|
||||
name = "django-picklefield"
|
||||
version = "3.2"
|
||||
@ -830,6 +863,140 @@ six = "*"
|
||||
[package.extras]
|
||||
test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.10.6"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"},
|
||||
{file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
pydantic-core = "2.27.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.27.2"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"},
|
||||
{file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"},
|
||||
{file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"},
|
||||
{file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"},
|
||||
{file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"},
|
||||
{file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"},
|
||||
{file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"},
|
||||
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"},
|
||||
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"},
|
||||
{file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.3"
|
||||
@ -1215,4 +1382,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "3a1c1cd04ceca7a53961a487d4e2659c53384d59a5d524e3548b0f0b3c4bbc57"
|
||||
content-hash = "6538de3a90b0a21de23da3768e8eeb6f520b09a61eb33454bb8a2b50444c14f5"
|
||||
|
@ -34,6 +34,7 @@ django-q2 = "^1.7.4"
|
||||
croniter = "^5.0.1"
|
||||
requests = "^2.32.3"
|
||||
pyyaml = "^6.0.2"
|
||||
django-ninja = "^1.3.0"
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user