From 99f3540825bc1ec02049e8780b92bb0941063f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 25 Mar 2025 22:46:01 +0100 Subject: [PATCH] Improve duration handling for sessions and games --- .../0012_alter_session_duration_calculated.py | 32 ++++++++++ games/migrations/0013_game_playtime.py | 35 +++++++++++ .../migrations/0014_session_duration_total.py | 19 ++++++ games/models.py | 61 +++++++++---------- games/signals.py | 15 ++++- games/templates/view_game.html | 2 +- games/views/game.py | 8 +-- games/views/session.py | 9 +-- 8 files changed, 132 insertions(+), 49 deletions(-) create mode 100644 games/migrations/0012_alter_session_duration_calculated.py create mode 100644 games/migrations/0013_game_playtime.py create mode 100644 games/migrations/0014_session_duration_total.py diff --git a/games/migrations/0012_alter_session_duration_calculated.py b/games/migrations/0012_alter_session_duration_calculated.py new file mode 100644 index 0000000..039a59a --- /dev/null +++ b/games/migrations/0012_alter_session_duration_calculated.py @@ -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(), + ), + ), + ] diff --git a/games/migrations/0013_game_playtime.py b/games/migrations/0013_game_playtime.py new file mode 100644 index 0000000..d18c836 --- /dev/null +++ b/games/migrations/0013_game_playtime.py @@ -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), + ] diff --git a/games/migrations/0014_session_duration_total.py b/games/migrations/0014_session_duration_total.py new file mode 100644 index 0000000..6044feb --- /dev/null +++ b/games/migrations/0014_session_duration_total.py @@ -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()), + ), + ] diff --git a/games/models.py b/games/models.py index ccfcdaf..34f6f6a 100644 --- a/games/models.py +++ b/games/models.py @@ -29,6 +29,8 @@ class Game(models.Model): "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) updated_at = models.DateTimeField(auto_now=True) @@ -78,6 +80,9 @@ class Game(models.Model): def unplayed(self): return self.status == self.Status.UNPLAYED + def playtime_formatted(self): + return format_duration(self.playtime, "%2.1H") + def save(self, *args, **kwargs): if self.platform is None: self.platform = get_sentinel_platform() @@ -153,13 +158,8 @@ class Purchase(models.Model): platform = models.ForeignKey( Platform, on_delete=models.CASCADE, default=None, null=True, blank=True ) - date_purchased = models.DateField() - date_refunded = 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) + date_purchased = models.DateField(verbose_name="Purchased") + date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded") infinite = models.BooleanField(default=False) price = models.FloatField(default=0) price_currency = models.CharField(max_length=3, default="USD") @@ -288,10 +288,23 @@ class Session(models.Model): default=None, related_name="sessions", ) - timestamp_start = models.DateTimeField() - timestamp_end = models.DateTimeField(blank=True, null=True) - duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) - duration_calculated = models.DurationField(blank=True, null=True) + timestamp_start = models.DateTimeField(verbose_name="Start") + timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End") + duration_manual = models.DurationField( + 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", on_delete=models.SET_DEFAULT, @@ -308,7 +321,7 @@ class Session(models.Model): objects = SessionQuerySet.as_manager() 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})" def finish_now(self): @@ -317,32 +330,18 @@ class Session(models.Model): def start_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: - result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") + result = format_duration(self.duration_total, "%02.1H") 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: 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: - 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): self.duration_manual = timedelta(0) diff --git a/games/signals.py b/games/signals.py index 5d986d6..5775ccf 100644 --- a/games/signals.py +++ b/games/signals.py @@ -1,8 +1,9 @@ -from django.db.models.signals import m2m_changed, pre_save +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.utils.timezone import now -from games.models import Game, GameStatusChange, Purchase +from games.models import Game, GameStatusChange, Purchase, Session @receiver(m2m_changed, sender=Purchase.games.through) @@ -12,6 +13,16 @@ def update_num_purchases(sender, instance, **kwargs): 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 0 + game.save(update_fields=["playtime"]) + + @receiver(pre_save, sender=Game) def game_status_changed(sender, instance, **kwargs): """ diff --git a/games/templates/view_game.html b/games/templates/view_game.html index 52ca8f0..e4bc92a 100644 --- a/games/templates/view_game.html +++ b/games/templates/view_game.html @@ -16,7 +16,7 @@ class="size-6"> - {{ hours_sum }} + {{ game.playtime_formatted }} HttpResponse: session_id=session.pk, ), f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", - ( - format_duration(session.duration_calculated, durationformat) - if session.duration_calculated - else f"{format_duration(session.duration_manual, durationformat_manual)}*" - ), + session.duration_formatted_with_mark, render_to_string( "cotton/button_group.html", { diff --git a/games/views/session.py b/games/views/session.py index d1e456c..b67fcf8 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -20,9 +20,6 @@ from common.components import ( ) from common.time import ( dateformat, - durationformat, - durationformat_manual, - format_duration, local_strftime, timeformat, ) @@ -130,11 +127,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse [ NameWithIcon(session_id=session.pk), f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", - ( - format_duration(session.duration_calculated, durationformat) - if session.duration_calculated - else f"{format_duration(session.duration_manual, durationformat_manual)}*" - ), + session.duration_formatted_with_mark, session.device, session.created_at.strftime(dateformat), render_to_string(