13 Commits

Author SHA1 Message Date
5cc1652002 Always set game status change timestamp to now instead of game's last updated_at
All checks were successful
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m15s
2025-06-07 20:28:14 +02:00
7cf2180192 Allow setting game to Finished when creating PlayEvent
All checks were successful
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-06-07 20:14:14 +02:00
ad0641f95b Fix playtime stats per year
All checks were successful
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 2m39s
2025-04-17 16:15:38 +02:00
abdcfdfe64 Redirect to previous page after editing a session
All checks were successful
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-04-01 15:36:40 +02:00
31daf2efe0 Make game overview playthrough dropdown close when clicked outside
All checks were successful
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-03-28 13:43:46 +01:00
6d53fca910 Always return timedelta in update_game_playtime
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m19s
2025-03-26 12:05:10 +01:00
f7e426e030 Make it easier to create a play event 2025-03-26 12:04:46 +01:00
b29e4edd72 Continue making use of improved duration handling
All checks were successful
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m22s
2025-03-25 23:38:04 +01:00
3c58851b88 Improve form fields for duration and currency
All checks were successful
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m10s
2025-03-25 22:46:17 +01:00
99f3540825 Improve duration handling for sessions and games 2025-03-25 22:46:01 +01:00
5e778bec30 Fix stats having hardcoded year
All checks were successful
Django CI/CD / test (push) Successful in 1m20s
Django CI/CD / build-and-push (push) Successful in 2m35s
2025-03-25 15:56:20 +01:00
fea9d9784d Fix purchase-name partial
All checks were successful
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-03-24 21:02:13 +01:00
23b4a7a069 Make it possible to edit and delete status changes
All checks were successful
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 2m18s
2025-03-22 23:45:02 +01:00
19 changed files with 378 additions and 120 deletions

View File

@ -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)

View File

@ -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,
}

View 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(),
),
),
]

View 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),
]

View 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()),
),
]

View File

@ -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)

View File

@ -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}")

View File

@ -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>

View 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>

View 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>

View File

@ -1,13 +1,18 @@
<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 %}
{% if purchase.game_name %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name />
{% else %} {% 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">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
@ -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>

View File

@ -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>

View 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)

View File

@ -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>",

View File

@ -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",
{ {

View File

@ -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()
) )

View File

@ -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()

View File

@ -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)

View 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})