Compare commits
13 Commits
89de85c00d
...
main
Author | SHA1 | Date | |
---|---|---|---|
5cc1652002
|
|||
7cf2180192
|
|||
ad0641f95b
|
|||
abdcfdfe64
|
|||
31daf2efe0
|
|||
6d53fca910
|
|||
f7e426e030
|
|||
b29e4edd72
|
|||
3c58851b88
|
|||
99f3540825
|
|||
5e778bec30
|
|||
fea9d9784d
|
|||
23b4a7a069
|
@ -13,7 +13,7 @@ durationformat_manual: str = "%H hours"
|
|||||||
|
|
||||||
|
|
||||||
def _safe_timedelta(duration: timedelta | int | None):
|
def _safe_timedelta(duration: timedelta | int | None):
|
||||||
if duration == None:
|
if duration is None:
|
||||||
return timedelta(0)
|
return timedelta(0)
|
||||||
elif isinstance(duration, int):
|
elif isinstance(duration, int):
|
||||||
return timedelta(seconds=duration)
|
return timedelta(seconds=duration)
|
||||||
|
@ -1,8 +1,17 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db import transaction
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.utils import safe_getattr
|
from common.utils import safe_getattr
|
||||||
from games.models import Device, Game, Platform, PlayEvent, Purchase, Session
|
from games.models import (
|
||||||
|
Device,
|
||||||
|
Game,
|
||||||
|
GameStatusChange,
|
||||||
|
Platform,
|
||||||
|
PlayEvent,
|
||||||
|
Purchase,
|
||||||
|
Session,
|
||||||
|
)
|
||||||
|
|
||||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||||
custom_datetime_widget = forms.DateTimeInput(
|
custom_datetime_widget = forms.DateTimeInput(
|
||||||
@ -27,6 +36,13 @@ class SessionForm(forms.ModelForm):
|
|||||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
duration_manual = forms.DurationField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
|
||||||
|
),
|
||||||
|
label="Manual duration",
|
||||||
|
)
|
||||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||||
|
|
||||||
mark_as_played = forms.BooleanField(
|
mark_as_played = forms.BooleanField(
|
||||||
@ -99,6 +115,18 @@ class PurchaseForm(forms.ModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
price_currency = forms.CharField(
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
"x-mask": "aaa",
|
||||||
|
"placeholder": "CZK",
|
||||||
|
"x-data": "",
|
||||||
|
"class": "uppercase",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
label="Currency",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
widgets = {
|
widgets = {
|
||||||
"date_purchased": custom_date_widget,
|
"date_purchased": custom_date_widget,
|
||||||
@ -200,15 +228,40 @@ class PlayEventForm(forms.ModelForm):
|
|||||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mark_as_finished = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial={"mark_as_finished": True},
|
||||||
|
label="Set game status to Finished",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PlayEvent
|
model = PlayEvent
|
||||||
fields = [
|
fields = ["game", "started", "ended", "note", "mark_as_finished"]
|
||||||
"game",
|
|
||||||
"started",
|
|
||||||
"ended",
|
|
||||||
"note",
|
|
||||||
]
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"started": custom_date_widget,
|
"started": custom_date_widget,
|
||||||
"ended": custom_date_widget,
|
"ended": custom_date_widget,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
with transaction.atomic():
|
||||||
|
session = super().save(commit=False)
|
||||||
|
if self.cleaned_data.get("mark_as_finished"):
|
||||||
|
game_instance = session.game
|
||||||
|
game_instance.status = "f"
|
||||||
|
game_instance.save()
|
||||||
|
session.save()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatusChangeForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = GameStatusChange
|
||||||
|
fields = [
|
||||||
|
"game",
|
||||||
|
"old_status",
|
||||||
|
"new_status",
|
||||||
|
"timestamp",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"timestamp": custom_datetime_widget,
|
||||||
|
}
|
||||||
|
32
games/migrations/0012_alter_session_duration_calculated.py
Normal file
32
games/migrations/0012_alter_session_duration_calculated.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-25 20:30
|
||||||
|
|
||||||
|
import django.db.models.expressions
|
||||||
|
import django.db.models.functions.comparison
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0011_purchase_price_per_game"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="session",
|
||||||
|
name="duration_calculated",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="session",
|
||||||
|
name="duration_calculated",
|
||||||
|
field=models.GeneratedField(
|
||||||
|
db_persist=True,
|
||||||
|
expression=django.db.models.functions.comparison.Coalesce(
|
||||||
|
django.db.models.expressions.CombinedExpression(
|
||||||
|
models.F("timestamp_end"), "-", models.F("timestamp_start")
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
output_field=models.DurationField(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
35
games/migrations/0013_game_playtime.py
Normal file
35
games/migrations/0013_game_playtime.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-25 20:33
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models import F, Sum
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_game_playtime(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
games = Game.objects.all()
|
||||||
|
for game in games:
|
||||||
|
total_playtime = game.sessions.aggregate(
|
||||||
|
total_playtime=Sum(F("duration_total"))
|
||||||
|
)["total_playtime"]
|
||||||
|
if total_playtime:
|
||||||
|
game.playtime = total_playtime
|
||||||
|
game.save(update_fields=["playtime"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0012_alter_session_duration_calculated"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="playtime",
|
||||||
|
field=models.DurationField(
|
||||||
|
blank=True, default=datetime.timedelta(0), editable=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(calculate_game_playtime),
|
||||||
|
]
|
19
games/migrations/0014_session_duration_total.py
Normal file
19
games/migrations/0014_session_duration_total.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-25 20:46
|
||||||
|
|
||||||
|
import django.db.models.expressions
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('games', '0013_game_playtime'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='session',
|
||||||
|
name='duration_total',
|
||||||
|
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
|
||||||
|
),
|
||||||
|
]
|
@ -29,6 +29,8 @@ class Game(models.Model):
|
|||||||
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@ -78,6 +80,9 @@ class Game(models.Model):
|
|||||||
def unplayed(self):
|
def unplayed(self):
|
||||||
return self.status == self.Status.UNPLAYED
|
return self.status == self.Status.UNPLAYED
|
||||||
|
|
||||||
|
def playtime_formatted(self):
|
||||||
|
return format_duration(self.playtime, "%2.1H")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.platform is None:
|
if self.platform is None:
|
||||||
self.platform = get_sentinel_platform()
|
self.platform = get_sentinel_platform()
|
||||||
@ -153,13 +158,8 @@ class Purchase(models.Model):
|
|||||||
platform = models.ForeignKey(
|
platform = models.ForeignKey(
|
||||||
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
|
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
|
||||||
)
|
)
|
||||||
date_purchased = models.DateField()
|
date_purchased = models.DateField(verbose_name="Purchased")
|
||||||
date_refunded = models.DateField(blank=True, null=True)
|
date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
|
||||||
# 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)
|
infinite = models.BooleanField(default=False)
|
||||||
price = models.FloatField(default=0)
|
price = models.FloatField(default=0)
|
||||||
price_currency = models.CharField(max_length=3, default="USD")
|
price_currency = models.CharField(max_length=3, default="USD")
|
||||||
@ -288,10 +288,23 @@ class Session(models.Model):
|
|||||||
default=None,
|
default=None,
|
||||||
related_name="sessions",
|
related_name="sessions",
|
||||||
)
|
)
|
||||||
timestamp_start = models.DateTimeField()
|
timestamp_start = models.DateTimeField(verbose_name="Start")
|
||||||
timestamp_end = models.DateTimeField(blank=True, null=True)
|
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
|
||||||
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
|
duration_manual = models.DurationField(
|
||||||
duration_calculated = models.DurationField(blank=True, null=True)
|
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
|
||||||
|
)
|
||||||
|
duration_calculated = GeneratedField(
|
||||||
|
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
|
||||||
|
output_field=models.DurationField(),
|
||||||
|
db_persist=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
duration_total = GeneratedField(
|
||||||
|
expression=F("duration_calculated") + F("duration_manual"),
|
||||||
|
output_field=models.DurationField(),
|
||||||
|
db_persist=True,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
device = models.ForeignKey(
|
device = models.ForeignKey(
|
||||||
"Device",
|
"Device",
|
||||||
on_delete=models.SET_DEFAULT,
|
on_delete=models.SET_DEFAULT,
|
||||||
@ -308,7 +321,7 @@ class Session(models.Model):
|
|||||||
objects = SessionQuerySet.as_manager()
|
objects = SessionQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
mark = ", manual" if self.is_manual() else ""
|
mark = "*" if self.is_manual() else ""
|
||||||
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
|
||||||
|
|
||||||
def finish_now(self):
|
def finish_now(self):
|
||||||
@ -317,32 +330,18 @@ class Session(models.Model):
|
|||||||
def start_now():
|
def start_now():
|
||||||
self.timestamp_start = timezone.now()
|
self.timestamp_start = timezone.now()
|
||||||
|
|
||||||
def duration_seconds(self) -> timedelta:
|
|
||||||
manual = timedelta(0)
|
|
||||||
calculated = timedelta(0)
|
|
||||||
if self.is_manual() and isinstance(self.duration_manual, timedelta):
|
|
||||||
manual = self.duration_manual
|
|
||||||
if self.timestamp_end is not None and self.timestamp_start is not None:
|
|
||||||
calculated = self.timestamp_end - self.timestamp_start
|
|
||||||
return timedelta(seconds=(manual + calculated).total_seconds())
|
|
||||||
|
|
||||||
def duration_formatted(self) -> str:
|
def duration_formatted(self) -> str:
|
||||||
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
|
result = format_duration(self.duration_total, "%02.1H")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def duration_formatted_with_mark(self) -> str:
|
||||||
|
mark = "*" if self.is_manual() else ""
|
||||||
|
return f"{self.duration_formatted()}{mark}"
|
||||||
|
|
||||||
def is_manual(self) -> bool:
|
def is_manual(self) -> bool:
|
||||||
return not self.duration_manual == timedelta(0)
|
return not self.duration_manual == timedelta(0)
|
||||||
|
|
||||||
@property
|
|
||||||
def duration_sum(self) -> str:
|
|
||||||
return Session.objects.all().total_duration_formatted()
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if self.timestamp_start is not None and self.timestamp_end is not None:
|
|
||||||
self.duration_calculated = self.timestamp_end - self.timestamp_start
|
|
||||||
else:
|
|
||||||
self.duration_calculated = timedelta(0)
|
|
||||||
|
|
||||||
if not isinstance(self.duration_manual, timedelta):
|
if not isinstance(self.duration_manual, timedelta):
|
||||||
self.duration_manual = timedelta(0)
|
self.duration_manual = timedelta(0)
|
||||||
|
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
from django.db.models.signals import m2m_changed, pre_save
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db.models import F, Sum
|
||||||
|
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from games.models import Game, GameStatusChange, Purchase
|
from games.models import Game, GameStatusChange, Purchase, Session
|
||||||
|
|
||||||
|
logger = logging.getLogger("games")
|
||||||
|
|
||||||
|
|
||||||
@receiver(m2m_changed, sender=Purchase.games.through)
|
@receiver(m2m_changed, sender=Purchase.games.through)
|
||||||
@ -12,6 +18,16 @@ def update_num_purchases(sender, instance, **kwargs):
|
|||||||
instance.save(update_fields=["num_purchases"])
|
instance.save(update_fields=["num_purchases"])
|
||||||
|
|
||||||
|
|
||||||
|
@receiver([post_save, post_delete], sender=Session)
|
||||||
|
def update_game_playtime(sender, instance, **kwargs):
|
||||||
|
game = instance.game
|
||||||
|
total_playtime = game.sessions.aggregate(
|
||||||
|
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
|
||||||
|
)["total_playtime"]
|
||||||
|
game.playtime = total_playtime if total_playtime else timedelta(0)
|
||||||
|
game.save(update_fields=["playtime"])
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=Game)
|
@receiver(pre_save, sender=Game)
|
||||||
def game_status_changed(sender, instance, **kwargs):
|
def game_status_changed(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -20,21 +36,23 @@ def game_status_changed(sender, instance, **kwargs):
|
|||||||
try:
|
try:
|
||||||
old_instance = sender.objects.get(pk=instance.pk)
|
old_instance = sender.objects.get(pk=instance.pk)
|
||||||
old_status = old_instance.status
|
old_status = old_instance.status
|
||||||
print("Got old instance")
|
logger.info("[game_status_changed]: Previous status exists.")
|
||||||
except sender.DoesNotExist:
|
except sender.DoesNotExist:
|
||||||
# Handle the case where the instance was deleted before the signal was sent
|
# Handle the case where the instance was deleted before the signal was sent
|
||||||
print("Instance does not exist")
|
logger.info("[game_status_changed]: Previous status does not exist.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if old_status != instance.status:
|
if old_status != instance.status:
|
||||||
print("Status changed")
|
logger.info(
|
||||||
|
"[game_status_changed]: Status changed from {} to {}".format(
|
||||||
|
old_status, instance.status
|
||||||
|
)
|
||||||
|
)
|
||||||
GameStatusChange.objects.create(
|
GameStatusChange.objects.create(
|
||||||
game=instance,
|
game=instance,
|
||||||
old_status=old_status,
|
old_status=old_status,
|
||||||
new_status=instance.status,
|
new_status=instance.status,
|
||||||
timestamp=instance.updated_at,
|
timestamp=now(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print("Status not changed")
|
logger.info("[game_status_changed]: Status has not changed")
|
||||||
print(f"{old_instance.status}")
|
|
||||||
print(f"{instance.status}")
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<c-vars color="blue" size="base" />
|
<c-vars color="blue" size="base" type="button" />
|
||||||
<button type="button"
|
<button type="{{ type }}"
|
||||||
title="{{ title }}"
|
title="{{ title }}"
|
||||||
class=" {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
|
class="{{ class }} {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
|
16
games/templates/gamestatuschange_confirm_delete.html
Normal file
16
games/templates/gamestatuschange_confirm_delete.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<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">
|
||||||
|
<form method="post" class="dark:text-white">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<p>Are you sure you want to delete this status change?</p>
|
||||||
|
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
|
||||||
|
<a href="{% url 'view_game' object.game.id %}" class="">
|
||||||
|
<c-button color="gray" class="w-full">Cancel</c-button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</c-layouts.base>
|
||||||
|
|
7
games/templates/gamestatuschange_list.html
Normal file
7
games/templates/gamestatuschange_list.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<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=["Test"] :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
||||||
|
</div>
|
||||||
|
</c-layouts.base>
|
||||||
|
|
@ -1,12 +1,17 @@
|
|||||||
<c-layouts.base>
|
<c-layouts.base>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load duration_formatter %}
|
||||||
{% partialdef purchase-name %}
|
{% partialdef purchase-name %}
|
||||||
{% if purchase.type != 'game' %}
|
{% if purchase.type != 'game' %}
|
||||||
<c-gamelink :game_id=purchase.first_game.id>
|
<c-gamelink :game_id=purchase.first_game.id>
|
||||||
{{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
|
{% if purchase.game_name %}{{ purchase.game_name }}{% else %}{{ purchase.name }}{% endif %} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
|
||||||
</c-gamelink>
|
</c-gamelink>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if purchase.game_name %}
|
||||||
|
<c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name />
|
||||||
|
{% else %}
|
||||||
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
|
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endpartialdef %}
|
{% endpartialdef %}
|
||||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
||||||
@ -107,7 +112,7 @@
|
|||||||
{% for month in month_playtimes %}
|
{% for month in month_playtimes %}
|
||||||
<tr>
|
<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">{{ month.month | date:"F" }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime | format_duration }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -153,7 +158,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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">Name</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">Playtime</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -162,7 +167,7 @@
|
|||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
<c-gamelink :game_id=game.id :name=game.name />
|
<c-gamelink :game_id=game.id :name=game.name />
|
||||||
</td>
|
</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 font-mono">{{ game.total_playtime | format_duration }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -172,14 +177,14 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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">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">Playtime</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in total_playtime_per_platform %}
|
{% for item in total_playtime_per_platform %}
|
||||||
<tr>
|
<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.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">{{ item.playtime | format_duration }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -196,7 +201,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for purchase in all_finished_this_year %}
|
{% for purchase in all_finished_this_year %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.game_name }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -209,14 +214,14 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
{% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for purchase in this_year_finished_this_year %}
|
{% for purchase in this_year_finished_this_year %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||||
{% 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 %}
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -228,14 +233,14 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
||||||
{% comment %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> {% endcomment %}
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for purchase in purchased_this_year_finished_this_year %}
|
{% for purchase in purchased_this_year_finished_this_year %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
||||||
{% 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 %}
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
class="size-6">
|
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" />
|
<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>
|
</svg>
|
||||||
{{ hours_sum }}
|
{{ game.playtime_formatted }}
|
||||||
</c-popover>
|
</c-popover>
|
||||||
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
|
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -74,7 +74,7 @@
|
|||||||
<span x-text="played"></span> times
|
<span x-text="played"></span> times
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</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">
|
<button type="button" x-on:click="open = !open" @click.outside="open = false" 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">
|
<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"/>
|
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -159,7 +159,7 @@
|
|||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-inside">
|
||||||
{% for change in statuschanges %}
|
{% for change in statuschanges %}
|
||||||
<li class="text-slate-500">
|
<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>
|
{% 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> (<a href="{% url 'edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'delete_statuschange' change.id %}">Delete</a>)</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
12
games/templatetags/duration_formatter.py
Normal file
12
games/templatetags/duration_formatter.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
from common.time import durationformat, format_duration
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name="format_duration")
|
||||||
|
def filter_format_duration(duration: timedelta, argument: str = durationformat):
|
||||||
|
return format_duration(duration, format_string=argument)
|
@ -1,7 +1,16 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from games.api import api
|
from games.api import api
|
||||||
from games.views import device, game, general, platform, playevent, purchase, session
|
from games.views import (
|
||||||
|
device,
|
||||||
|
game,
|
||||||
|
general,
|
||||||
|
platform,
|
||||||
|
playevent,
|
||||||
|
purchase,
|
||||||
|
session,
|
||||||
|
statuschange,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", general.index, name="index"),
|
path("", general.index, name="index"),
|
||||||
@ -127,6 +136,26 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("session/list", session.list_sessions, name="list_sessions"),
|
path("session/list", session.list_sessions, name="list_sessions"),
|
||||||
path("session/search", session.search_sessions, name="search_sessions"),
|
path("session/search", session.search_sessions, name="search_sessions"),
|
||||||
|
path(
|
||||||
|
"statuschange/add",
|
||||||
|
statuschange.AddStatusChangeView.as_view(),
|
||||||
|
name="add_statuschange",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"statuschange/edit/<int:statuschange_id>",
|
||||||
|
statuschange.EditStatusChangeView.as_view(),
|
||||||
|
name="edit_statuschange",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"statuschange/delete/<int:pk>",
|
||||||
|
statuschange.GameStatusChangeDeleteView.as_view(),
|
||||||
|
name="delete_statuschange",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"statuschange/list",
|
||||||
|
statuschange.GameStatusChangeListView.as_view(),
|
||||||
|
name="list_statuschanges",
|
||||||
|
),
|
||||||
path("stats/", general.stats_alltime, name="stats_alltime"),
|
path("stats/", general.stats_alltime, name="stats_alltime"),
|
||||||
path(
|
path(
|
||||||
"stats/<int:year>",
|
"stats/<int:year>",
|
||||||
|
@ -22,8 +22,6 @@ from common.components import (
|
|||||||
)
|
)
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
durationformat,
|
|
||||||
durationformat_manual,
|
|
||||||
format_duration,
|
format_duration,
|
||||||
local_strftime,
|
local_strftime,
|
||||||
timeformat,
|
timeformat,
|
||||||
@ -310,11 +308,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
session_id=session.pk,
|
session_id=session.pk,
|
||||||
),
|
),
|
||||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||||
(
|
session.duration_formatted_with_mark,
|
||||||
format_duration(session.duration_calculated, durationformat)
|
|
||||||
if session.duration_calculated
|
|
||||||
else f"{format_duration(session.duration_manual, durationformat_manual)}*"
|
|
||||||
),
|
|
||||||
render_to_string(
|
render_to_string(
|
||||||
"cotton/button_group.html",
|
"cotton/button_group.html",
|
||||||
{
|
{
|
||||||
|
@ -22,10 +22,10 @@ def model_counts(request: HttpRequest) -> dict[str, bool]:
|
|||||||
timestamp_start__day=this_day,
|
timestamp_start__day=this_day,
|
||||||
timestamp_start__month=this_month,
|
timestamp_start__month=this_month,
|
||||||
timestamp_start__year=this_year,
|
timestamp_start__year=this_year,
|
||||||
).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"]
|
).aggregate(time=Sum(F("duration_total")))["time"]
|
||||||
last_7_played = Session.objects.filter(
|
last_7_played = Session.objects.filter(
|
||||||
timestamp_start__gte=(now - timedelta(days=7))
|
timestamp_start__gte=(now - timedelta(days=7))
|
||||||
).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"]
|
).aggregate(time=Sum(F("duration_total")))["time"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"game_available": Game.objects.exists(),
|
"game_available": Game.objects.exists(),
|
||||||
@ -137,19 +137,13 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
)
|
)
|
||||||
total_spent = this_year_spendings["total_spent"] or 0
|
total_spent = this_year_spendings["total_spent"] or 0
|
||||||
|
|
||||||
games_with_playtime = (
|
games_with_playtime = Game.objects.filter(
|
||||||
Game.objects.filter(sessions__in=this_year_sessions)
|
sessions__in=this_year_sessions
|
||||||
.annotate(
|
).distinct()
|
||||||
total_playtime=Sum(
|
|
||||||
F("sessions__duration_calculated") + F("sessions__duration_manual")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values("id", "name", "total_playtime")
|
|
||||||
)
|
|
||||||
month_playtimes = (
|
month_playtimes = (
|
||||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||||
.values("month")
|
.values("month")
|
||||||
.annotate(playtime=Sum("duration_calculated"))
|
.annotate(playtime=Sum("duration_total"))
|
||||||
.order_by("month")
|
.order_by("month")
|
||||||
)
|
)
|
||||||
for month in month_playtimes:
|
for month in month_playtimes:
|
||||||
@ -162,18 +156,14 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
|
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
|
||||||
for game in top_10_games_by_playtime:
|
|
||||||
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
|
||||||
|
|
||||||
total_playtime_per_platform = (
|
total_playtime_per_platform = (
|
||||||
this_year_sessions.values("game__platform__name")
|
this_year_sessions.values("game__platform__name")
|
||||||
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
.annotate(playtime=Sum(F("duration_total")))
|
||||||
.annotate(platform_name=F("game__platform__name"))
|
.annotate(platform_name=F("game__platform__name"))
|
||||||
.values("platform_name", "total_playtime")
|
.values("platform_name", "playtime")
|
||||||
.order_by("-total_playtime")
|
.order_by("-playtime")
|
||||||
)
|
)
|
||||||
for item in total_playtime_per_platform:
|
|
||||||
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
|
||||||
|
|
||||||
backlog_decrease_count = (
|
backlog_decrease_count = (
|
||||||
Purchase.objects.all().intersection(purchases_finished_this_year).count()
|
Purchase.objects.all().intersection(purchases_finished_this_year).count()
|
||||||
@ -354,7 +344,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
purchases_finished_this_year = Purchase.objects.filter(
|
purchases_finished_this_year = Purchase.objects.filter(
|
||||||
games__playevents__ended__year=2025
|
games__playevents__ended__year=year
|
||||||
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
||||||
purchases_finished_this_year_released_this_year = (
|
purchases_finished_this_year_released_this_year = (
|
||||||
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
||||||
@ -362,7 +352,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
purchased_this_year_finished_this_year = (
|
purchased_this_year_finished_this_year = (
|
||||||
this_year_purchases_without_refunded.filter(games__playevents__ended__year=year)
|
this_year_purchases_without_refunded.filter(
|
||||||
|
games__playevents__ended__year=year
|
||||||
|
).annotate(
|
||||||
|
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||||
|
)
|
||||||
).order_by("games__playevents__ended")
|
).order_by("games__playevents__ended")
|
||||||
|
|
||||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||||
@ -371,22 +365,21 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
total_spent = this_year_spendings["total_spent"] or 0
|
total_spent = this_year_spendings["total_spent"] or 0
|
||||||
|
|
||||||
games_with_playtime = (
|
games_with_playtime = (
|
||||||
Game.objects.filter(sessions__in=this_year_sessions)
|
Game.objects.filter(sessions__timestamp_start__year=year)
|
||||||
.annotate(
|
.annotate(
|
||||||
total_playtime=Sum(
|
total_playtime=Sum(
|
||||||
F("sessions__duration_calculated") + F("sessions__duration_manual")
|
F("sessions__duration_calculated"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values("id", "name", "total_playtime")
|
.filter(total_playtime__gt=timedelta(0))
|
||||||
)
|
)
|
||||||
|
|
||||||
month_playtimes = (
|
month_playtimes = (
|
||||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||||
.values("month")
|
.values("month")
|
||||||
.annotate(playtime=Sum("duration_calculated"))
|
.annotate(playtime=Sum("duration_total"))
|
||||||
.order_by("month")
|
.order_by("month")
|
||||||
)
|
)
|
||||||
for month in month_playtimes:
|
|
||||||
month["playtime"] = format_duration(month["playtime"], "%2.0H")
|
|
||||||
|
|
||||||
highest_session_average_game = (
|
highest_session_average_game = (
|
||||||
Game.objects.filter(sessions__in=this_year_sessions)
|
Game.objects.filter(sessions__in=this_year_sessions)
|
||||||
@ -395,23 +388,19 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
|
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
|
||||||
for game in top_10_games_by_playtime:
|
|
||||||
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
|
||||||
|
|
||||||
total_playtime_per_platform = (
|
total_playtime_per_platform = (
|
||||||
this_year_sessions.values("game__platform__name")
|
this_year_sessions.values("game__platform__name")
|
||||||
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
.annotate(playtime=Sum(F("duration_total")))
|
||||||
.annotate(platform_name=F("game__platform__name"))
|
.annotate(platform_name=F("game__platform__name"))
|
||||||
.values("platform_name", "total_playtime")
|
.values("platform_name", "playtime")
|
||||||
.order_by("-total_playtime")
|
.order_by("-playtime")
|
||||||
)
|
)
|
||||||
for item in total_playtime_per_platform:
|
|
||||||
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
|
||||||
|
|
||||||
backlog_decrease_count = (
|
backlog_decrease_count = (
|
||||||
Purchase.objects.filter(date_purchased__year__lt=2025)
|
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||||
.filter(games__status="f")
|
.filter(games__status="f")
|
||||||
.filter(games__playevents__ended__year=2025)
|
.filter(games__playevents__ended__year=year)
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -115,6 +115,8 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
# coming from add_playevent_for_game url path
|
# coming from add_playevent_for_game url path
|
||||||
game = get_object_or_404(Game, id=game_id)
|
game = get_object_or_404(Game, id=game_id)
|
||||||
initial["game"] = game
|
initial["game"] = game
|
||||||
|
initial["started"] = game.sessions.earliest().timestamp_start
|
||||||
|
initial["ended"] = game.sessions.latest().timestamp_start
|
||||||
form = PlayEventForm(request.POST or None, initial=initial)
|
form = PlayEventForm(request.POST or None, initial=initial)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
|
@ -20,16 +20,12 @@ from common.components import (
|
|||||||
)
|
)
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
durationformat,
|
|
||||||
durationformat_manual,
|
|
||||||
format_duration,
|
|
||||||
local_strftime,
|
local_strftime,
|
||||||
timeformat,
|
timeformat,
|
||||||
)
|
)
|
||||||
from common.utils import truncate
|
from common.utils import truncate
|
||||||
from games.forms import SessionForm
|
from games.forms import SessionForm
|
||||||
from games.models import Game, Session
|
from games.models import Game, Session
|
||||||
from games.views.general import use_custom_redirect
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -130,11 +126,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
[
|
[
|
||||||
NameWithIcon(session_id=session.pk),
|
NameWithIcon(session_id=session.pk),
|
||||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||||
(
|
session.duration_formatted_with_mark,
|
||||||
format_duration(session.duration_calculated, durationformat)
|
|
||||||
if session.duration_calculated
|
|
||||||
else f"{format_duration(session.duration_manual, durationformat_manual)}*"
|
|
||||||
),
|
|
||||||
session.device,
|
session.device,
|
||||||
session.created_at.strftime(dateformat),
|
session.created_at.strftime(dateformat),
|
||||||
render_to_string(
|
render_to_string(
|
||||||
@ -222,7 +214,6 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@use_custom_redirect
|
|
||||||
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||||
context = {}
|
context = {}
|
||||||
session = get_object_or_404(Session, id=session_id)
|
session = get_object_or_404(Session, id=session_id)
|
||||||
|
57
games/views/statuschange.py
Normal file
57
games/views/statuschange.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
|
||||||
|
|
||||||
|
from games.forms import GameStatusChangeForm
|
||||||
|
from games.models import GameStatusChange
|
||||||
|
|
||||||
|
|
||||||
|
class EditStatusChangeView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = GameStatusChange
|
||||||
|
form_class = GameStatusChangeForm
|
||||||
|
template_name = "add.html"
|
||||||
|
context_object_name = "form"
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(GameStatusChange, id=self.kwargs["statuschange_id"])
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy("list_platforms")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["title"] = "Edit Platform"
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class AddStatusChangeView(LoginRequiredMixin, CreateView):
|
||||||
|
model = GameStatusChange
|
||||||
|
form_class = GameStatusChangeForm
|
||||||
|
template_name = "add.html"
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy("view_game", kwargs={"pk": self.object.game.id})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["title"] = "Add status change"
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatusChangeListView(LoginRequiredMixin, ListView):
|
||||||
|
model = GameStatusChange
|
||||||
|
template_name = "list_purchases.html"
|
||||||
|
context_object_name = "status_changes"
|
||||||
|
paginate_by = 10
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return GameStatusChange.objects.select_related("game").all()
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatusChangeDeleteView(LoginRequiredMixin, DeleteView):
|
||||||
|
model = GameStatusChange
|
||||||
|
template_name = "gamestatuschange_confirm_delete.html"
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy("view_game", kwargs={"game_id": self.object.game.id})
|
Reference in New Issue
Block a user