Improve duration handling for sessions and games
This commit is contained in:
parent
5e778bec30
commit
99f3540825
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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -16,7 +16,7 @@
|
||||
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" />
|
||||
</svg>
|
||||
{{ hours_sum }}
|
||||
{{ game.playtime_formatted }}
|
||||
</c-popover>
|
||||
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -22,8 +22,6 @@ from common.components import (
|
||||
)
|
||||
from common.time import (
|
||||
dateformat,
|
||||
durationformat,
|
||||
durationformat_manual,
|
||||
format_duration,
|
||||
local_strftime,
|
||||
timeformat,
|
||||
@ -310,11 +308,7 @@ def view_game(request: HttpRequest, game_id: int) -> 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",
|
||||
{
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user