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