Fix more code smells
Django CI/CD / test (push) Successful in 39s
Django CI/CD / build-and-push (push) Successful in 1m19s

This commit is contained in:
2026-06-06 13:14:55 +02:00
parent f4161bf3f4
commit ed8589a972
44 changed files with 4898 additions and 3164 deletions
+3 -2
View File
@@ -104,7 +104,9 @@ class SessionDeviceUpdate(Schema):
@session_router.patch("/{session_id}/device", response={204: None})
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
def partial_update_session_device(
request, session_id: int, payload: SessionDeviceUpdate
):
session = get_object_or_404(Session, id=session_id)
session.device_id = payload.device_id
session.save()
@@ -113,4 +115,3 @@ def partial_update_session_device(request, session_id: int, payload: SessionDevi
api.add_router("/session", session_router)
+55 -38
View File
@@ -13,6 +13,8 @@ from __future__ import annotations
from dataclasses import dataclass
from django.db.models import Q
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
@@ -32,11 +34,11 @@ from common.criteria import (
class FindFilter:
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
q: str | None = None # free-text search
q: str | None = None # free-text search
page: int = 1
per_page: int = 25
sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc
sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc
# ── GameFilter ─────────────────────────────────────────────────────────────
@@ -55,19 +57,17 @@ class GameFilter(OperatorFilter):
year_released: IntCriterion | None = None
original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget
status: ChoiceCriterion | None = None # selectable filter widget
platform: ChoiceCriterion | None = None # selectable filter widget
status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
def to_q(self) -> Q:
q = Q()
# ── individual criteria ──
@@ -118,7 +118,7 @@ class GameFilter(OperatorFilter):
return q
@staticmethod
def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
def _playtime_to_q(c: IntCriterion) -> Q:
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
@@ -127,16 +127,25 @@ class GameFilter(OperatorFilter):
from datetime import timedelta
from common.criteria import Modifier
from django.db.models import Q
m = c.modifier
field = "playtime"
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
@@ -167,15 +176,15 @@ class SessionFilter(OperatorFilter):
OR: SessionFilter | None = None
NOT: SessionFilter | None = None
game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id
game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0
duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0
created_at: StringCriterion | None = None
# Free-text search
@@ -184,11 +193,9 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
def to_q(self) -> Q:
from datetime import timedelta
from django.db.models import Q
q = Q()
if self.game is not None:
@@ -205,9 +212,19 @@ class SessionFilter(OperatorFilter):
field = "duration_total"
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
@@ -256,6 +273,7 @@ class SessionFilter(OperatorFilter):
# Cross-entity filter: sessions for games matching GameFilter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
@@ -285,17 +303,17 @@ class PurchaseFilter(OperatorFilter):
NOT: PurchaseFilter | None = None
name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None
price_currency: StringCriterion | None = None
num_purchases: IntCriterion | None = None
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None
@@ -305,9 +323,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
def to_q(self) -> Q:
q = Q()
if self.name is not None:
@@ -353,6 +369,7 @@ class PurchaseFilter(OperatorFilter):
# Cross-entity filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
+3 -1
View File
@@ -43,7 +43,9 @@ class SessionForm(forms.ModelForm):
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False)
device = forms.ModelChoiceField(
queryset=Device.objects.order_by("name"), required=False
)
mark_as_played = forms.BooleanField(
required=False,
+4 -2
View File
@@ -34,9 +34,11 @@ class HTMXMessagesMiddleware:
if "HX-Redirect" in response:
return response
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
min_level = (
message_constants.DEBUG if settings.DEBUG else message_constants.INFO
)
backend = django_messages.get_messages(request)
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
if hasattr(backend, "_set_level") and backend._get_level() > min_level:
backend._set_level(min_level)
messages = list(backend)
if not messages:
+227 -61
View File
@@ -6,99 +6,265 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Device',
name="Device",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("PC", "PC"),
("Console", "Console"),
("Handheld", "Handheld"),
("Mobile", "Mobile"),
("Single-board computer", "Single-board computer"),
("Unknown", "Unknown"),
],
default="Unknown",
max_length=255,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Platform',
name="Platform",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
('icon', models.SlugField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"group",
models.CharField(
blank=True, default=None, max_length=255, null=True
),
),
("icon", models.SlugField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='ExchangeRate',
name="ExchangeRate",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("currency_from", models.CharField(max_length=255)),
("currency_to", models.CharField(max_length=255)),
("year", models.PositiveIntegerField()),
("rate", models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
"unique_together": {("currency_from", "currency_to", "year")},
},
),
migrations.CreateModel(
name='Game',
name="Game",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
('year_released', models.IntegerField(blank=True, default=None, null=True)),
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"sort_name",
models.CharField(
blank=True, default=None, max_length=255, null=True
),
),
(
"year_released",
models.IntegerField(blank=True, default=None, null=True),
),
(
"wikidata",
models.CharField(
blank=True, default=None, max_length=50, null=True
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"platform",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.platform",
),
),
],
options={
'unique_together': {('name', 'platform', 'year_released')},
"unique_together": {("name", "platform", "year_released")},
},
),
migrations.CreateModel(
name='Purchase',
name="Purchase",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_purchased', models.DateField()),
('date_refunded', models.DateField(blank=True, null=True)),
('date_finished', models.DateField(blank=True, null=True)),
('date_dropped', models.DateField(blank=True, null=True)),
('infinite', models.BooleanField(default=False)),
('price', models.FloatField(default=0)),
('price_currency', models.CharField(default='USD', max_length=3)),
('converted_price', models.FloatField(null=True)),
('converted_currency', models.CharField(max_length=3, null=True)),
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date_purchased", models.DateField()),
("date_refunded", models.DateField(blank=True, null=True)),
("date_finished", models.DateField(blank=True, null=True)),
("date_dropped", models.DateField(blank=True, null=True)),
("infinite", models.BooleanField(default=False)),
("price", models.FloatField(default=0)),
("price_currency", models.CharField(default="USD", max_length=3)),
("converted_price", models.FloatField(null=True)),
("converted_currency", models.CharField(max_length=3, null=True)),
(
"ownership_type",
models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
(
"type",
models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
(
"name",
models.CharField(blank=True, default="", max_length=255, null=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"games",
models.ManyToManyField(
blank=True, related_name="purchases", to="games.game"
),
),
(
"platform",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
(
"related_purchase",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
],
),
migrations.CreateModel(
name='Session',
name="Session",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp_start', models.DateTimeField()),
('timestamp_end', models.DateTimeField(blank=True, null=True)),
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
('duration_calculated', models.DurationField(blank=True, null=True)),
('note', models.TextField(blank=True, null=True)),
('emulated', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timestamp_start", models.DateTimeField()),
("timestamp_end", models.DateTimeField(blank=True, null=True)),
(
"duration_manual",
models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
("duration_calculated", models.DurationField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
("emulated", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("modified_at", models.DateTimeField(auto_now=True)),
(
"device",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.device",
),
),
(
"game",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="sessions",
to="games.game",
),
),
],
options={
'get_latest_by': 'timestamp_start',
"get_latest_by": "timestamp_start",
},
),
]
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0001_initial'),
("games", "0001_initial"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
model_name="purchase",
name="price_per_game",
field=models.FloatField(null=True),
),
]
+3 -4
View File
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0002_purchase_price_per_game'),
("games", "0002_purchase_price_per_game"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='updated_at',
model_name="purchase",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
]
@@ -5,55 +5,66 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0005_game_mastered_game_status'),
("games", "0005_game_mastered_game_status"),
]
operations = [
migrations.AlterField(
model_name='game',
name='sort_name',
field=models.CharField(blank=True, default='', max_length=255),
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AlterField(
model_name='game',
name='wikidata',
field=models.CharField(blank=True, default='', max_length=50),
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default="", max_length=50),
),
migrations.AlterField(
model_name='platform',
name='group',
field=models.CharField(blank=True, default='', max_length=255),
model_name="platform",
name="group",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='converted_currency',
field=models.CharField(blank=True, default='', max_length=3),
model_name="purchase",
name="converted_currency",
field=models.CharField(blank=True, default="", max_length=3),
),
migrations.AlterField(
model_name='purchase',
name='games',
field=models.ManyToManyField(related_name='purchases', to='games.game'),
model_name="purchase",
name="games",
field=models.ManyToManyField(related_name="purchases", to="games.game"),
),
migrations.AlterField(
model_name='purchase',
name='name',
field=models.CharField(blank=True, default='', max_length=255),
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='related_purchase',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
migrations.AlterField(
model_name='session',
name='game',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
model_name="session",
name="game",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="sessions",
to="games.game",
),
),
migrations.AlterField(
model_name='session',
name='note',
field=models.TextField(blank=True, default=''),
model_name="session",
name="note",
field=models.TextField(blank=True, default=""),
),
]
+3 -4
View File
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
]
operations = [
migrations.AddField(
model_name='game',
name='updated_at',
model_name="game",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
]
@@ -4,18 +4,17 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
("games", "0008_game_original_year_released_gamestatuschange_and_more"),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='date_dropped',
model_name="purchase",
name="date_dropped",
),
migrations.RemoveField(
model_name='purchase',
name='date_finished',
model_name="purchase",
name="date_finished",
),
]
@@ -4,14 +4,13 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'),
("games", "0009_remove_purchase_date_dropped_and_more"),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='price_per_game',
model_name="purchase",
name="price_per_game",
),
]
@@ -6,15 +6,24 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0010_remove_purchase_price_per_game'),
("games", "0010_remove_purchase_price_per_game"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
model_name="purchase",
name="price_per_game",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.CombinedExpression(
django.db.models.functions.comparison.Coalesce(
models.F("converted_price"), models.F("price"), 0
),
"/",
models.F("num_purchases"),
),
output_field=models.FloatField(),
),
),
]
@@ -5,15 +5,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0013_game_playtime'),
("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()),
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(),
),
),
]
@@ -5,35 +5,39 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0014_session_duration_total'),
("games", "0014_session_duration_total"),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='date_purchased',
field=models.DateField(verbose_name='Purchased'),
model_name="purchase",
name="date_purchased",
field=models.DateField(verbose_name="Purchased"),
),
migrations.AlterField(
model_name='purchase',
name='date_refunded',
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
model_name="purchase",
name="date_refunded",
field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
),
migrations.AlterField(
model_name='session',
name='duration_manual',
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True,
default=datetime.timedelta(0),
null=True,
verbose_name="Manual duration",
),
),
migrations.AlterField(
model_name='session',
name='timestamp_end',
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
model_name="session",
name="timestamp_end",
field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
),
migrations.AlterField(
model_name='session',
name='timestamp_start',
field=models.DateTimeField(verbose_name='Start'),
model_name="session",
name="timestamp_start",
field=models.DateTimeField(verbose_name="Start"),
),
]
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0015_alter_purchase_date_purchased_and_more'),
("games", "0015_alter_purchase_date_purchased_and_more"),
]
operations = [
migrations.AddField(
model_name='purchase',
name='needs_price_update',
model_name="purchase",
name="needs_price_update",
field=models.BooleanField(db_index=True, default=True),
),
migrations.RunSQL(
+31 -12
View File
@@ -4,26 +4,45 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0016_add_needs_price_update'),
("games", "0016_add_needs_price_update"),
]
operations = [
migrations.CreateModel(
name='FilterPreset',
name="FilterPreset",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)),
('find_filter', models.JSONField(blank=True, default=dict)),
('object_filter', models.JSONField(blank=True, default=dict)),
('ui_options', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"mode",
models.CharField(
choices=[
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
],
default="games",
max_length=50,
),
),
("find_filter", models.JSONField(blank=True, default=dict)),
("object_filter", models.JSONField(blank=True, default=dict)),
("ui_options", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
"ordering": ["name"],
},
),
]
+4 -2
View File
@@ -66,8 +66,10 @@ class Game(models.Model):
return self.name
def finished(self):
return (self.status == self.Status.FINISHED or
self.playevents.filter(ended__isnull=False).exists())
return (
self.status == self.Status.FINISHED
or self.playevents.filter(ended__isnull=False).exists()
)
def abandoned(self):
return self.status == self.Status.ABANDONED
+3 -1
View File
@@ -60,7 +60,9 @@ def _save_converted_price(purchase, converted_price, needs_update):
purchase.converted_currency = currency_to
if needs_update:
purchase.needs_price_update = False
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
purchase.save(
update_fields=["converted_price", "converted_currency", "needs_price_update"]
)
def convert_prices():
+1 -1
View File
@@ -9,5 +9,5 @@ register = template.Library()
def randomid(seed: str = "") -> str:
content_hash = hashlib.sha1(seed.encode()).hexdigest()
if seed:
return content_hash[:max(0, 10 - len(seed))] + seed
return content_hash[: max(0, 10 - len(seed))] + seed
return content_hash[:10]
-102
View File
@@ -1,102 +0,0 @@
from datetime import date
from django.test import TestCase
from games.models import Game, Platform, Purchase
from games.tasks import convert_prices
class PurchaseNeedsPriceUpdateTest(TestCase):
def setUp(self):
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
self.game = Game.objects.create(name="Test Game", platform=self.platform)
def test_new_purchase_has_needs_price_update_true(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
self.assertTrue(purchase.needs_price_update)
def test_convert_prices_sets_flag_to_false(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
self.assertTrue(purchase.needs_price_update)
convert_prices()
purchase.refresh_from_db()
self.assertFalse(purchase.needs_price_update)
def test_price_change_sets_needs_price_update(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
purchase.price = 60.0
purchase.save()
purchase.refresh_from_db()
self.assertTrue(purchase.needs_price_update)
def test_currency_change_sets_needs_price_update(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
purchase.price_currency = "EUR"
purchase.save()
purchase.refresh_from_db()
self.assertTrue(purchase.needs_price_update)
def test_name_change_does_not_set_needs_price_update(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
purchase.name = "New Name"
purchase.save()
purchase.refresh_from_db()
self.assertFalse(purchase.needs_price_update)
def test_convert_prices_skips_already_converted(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
convert_prices()
purchase.refresh_from_db()
self.assertFalse(purchase.needs_price_update)
+6 -2
View File
@@ -23,7 +23,11 @@ urlpatterns = [
path("game/add", game.add_game, name="add_game"),
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
path("game/<int:game_id>/view", game.view_game, name="view_game"),
path("game/<int:game_id>/delete/confirm", game.delete_game_confirmation, name="delete_game_confirmation"),
path(
"game/<int:game_id>/delete/confirm",
game.delete_game_confirmation,
name="delete_game_confirmation",
),
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
path("game/list", game.list_games, name="list_games"),
path("platform/add", platform.add_platform, name="add_platform"),
@@ -175,4 +179,4 @@ urlpatterns = [
filter_presets.load_preset,
name="load_preset",
),
]
]
+5 -12
View File
@@ -5,10 +5,10 @@ from urllib.parse import quote
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from django.utils.safestring import mark_safe
from games.models import FilterPreset
@@ -21,9 +21,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
items: list[str] = []
for preset in presets:
filter_json = (
json.dumps(preset.object_filter) if preset.object_filter else ""
)
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
list_url = reverse(f"games:list_{mode}")
delete_url = reverse("games:delete_preset", args=[preset.id])
@@ -40,14 +38,9 @@ def list_presets(request: HttpRequest) -> HttpResponse:
)
if not items:
items = [
'<li class="px-4 py-2 text-sm text-body italic">'
"No saved presets</li>"
]
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
return HttpResponse(
mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')
)
return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>'))
@login_required
+177 -188
View File
@@ -148,7 +148,9 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
request,
content,
title="Manage games",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
@@ -540,159 +542,34 @@ def _game_section(
)
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
purchases = game.purchases.order_by("date_purchased")
def _game_overview_metrics(game: Game) -> dict[str, Any]:
"""Request-free header metrics: total session count, play range, and the
per-session average (excluding manually-logged sessions)."""
sessions = game.sessions
session_count = sessions.count()
session_count_without_manual = game.sessions.without_manual().count()
session_count_without_manual = sessions.without_manual().count()
if sessions.exists():
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
latest_session = sessions.latest()
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
end = local_strftime(sessions.latest().timestamp_start, "%b %Y")
playrange = start if start == end else f"{start}{end}"
else:
playrange = "N/A"
latest_session = None
total_hours_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
purchase_data: dict[str, Any] = {
"columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [
[
LinkedPurchase(purchase),
purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat),
PurchasePrice(purchase),
ButtonGroup(
[
{
"href": reverse("games:edit_purchase", args=[purchase.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse(
"games:delete_purchase", args=[purchase.pk]
),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for purchase in purchases
],
}
sessions_all = game.sessions.order_by("-timestamp_start")
last_session = None
if sessions_all.exists():
last_session = sessions_all.latest()
session_count = sessions_all.count()
session_paginator = Paginator(sessions_all, 5)
page_number = request.GET.get("page", 1)
session_page_obj = session_paginator.get_page(page_number)
sessions = session_page_obj.object_list
session_data: dict[str, Any] = {
"header_action": Div(
children=[
A(
url_name="games:add_session",
children=Button(
icon=True,
size="xs",
children=[Icon("play"), "LOG"],
),
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.game.name}"),
],
)
],
),
)
if last_session
else "",
],
),
"columns": ["Game", "Date", "Duration", "Actions"],
"rows": [
[
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
ButtonGroup(
[
{
"href": reverse(
"games:list_sessions_end_session", args=[session.pk]
),
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_session", args=[session.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for session in sessions
],
}
playevents = game.playevents.all()
playevent_count = playevents.count()
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
purchase_count = game.purchases.count()
status_selector_html = GameStatusSelector(
game, Game.Status.choices, get_token(request)
)
session_average_without_manual = round(
safe_division(total_hours_without_manual, int(session_count_without_manual)),
1,
safe_division(total_hours_without_manual, int(session_count_without_manual)), 1
)
return {
"session_count": session_count,
"playrange": playrange,
"session_average_without_manual": session_average_without_manual,
}
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
grey_value_class = "text-black dark:text-slate-300"
title_span = Component(
tag_name="span",
@@ -718,8 +595,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
else []
),
)
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
stats_row = Div(
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
[
@@ -730,23 +605,25 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game.playtime_formatted(),
),
_stat_popover(
"popover-sessions", "Number of sessions", "sessions", session_count
"popover-sessions",
"Number of sessions",
"sessions",
metrics["session_count"],
),
_stat_popover(
"popover-average",
"Average playtime per session",
"average",
session_average_without_manual,
metrics["session_average_without_manual"],
),
_stat_popover(
"popover-playrange",
"Earliest and latest dates played",
"playrange",
playrange,
metrics["playrange"],
),
],
)
metadata = Div(
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
[
@@ -758,7 +635,11 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
children=[str(game.original_year_released)],
),
),
_meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
_meta_row(
"Status",
GameStatusSelector(game, Game.Status.choices, get_token(request)),
"👑" if game.mastered else "",
),
_played_row(game, request),
_meta_row(
"Platform",
@@ -770,36 +651,144 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
),
],
)
game_info = Div(
return Div(
[("id", "game-info"), ("class", "mb-10")],
[title_row, stats_row, metadata, _game_action_buttons(game)],
[
Div([("class", "flex gap-5 mb-3")], [title_span]),
stats_row,
metadata,
_game_action_buttons(game),
],
)
session_elided_page_range = (
session_page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if session_page_obj and session_count > 5
def _purchases_section(game: Game) -> SafeText:
purchases = game.purchases.order_by("date_purchased")
rows = [
[
LinkedPurchase(purchase),
purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat),
PurchasePrice(purchase),
ButtonGroup(
[
{
"href": reverse("games:edit_purchase", args=[purchase.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_purchase", args=[purchase.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for purchase in purchases
]
table = SimpleTable(columns=["Name", "Type", "Date", "Price", "Actions"], rows=rows)
return _game_section("Purchases", purchases.count(), table, "No purchases yet.")
def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
sessions_all = game.sessions.order_by("-timestamp_start")
session_count = sessions_all.count()
last_session = sessions_all.latest() if sessions_all.exists() else None
page_number = request.GET.get("page", 1)
page_obj = Paginator(sessions_all, 5).get_page(page_number)
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if session_count > 5
else None
)
purchases_table = SimpleTable(
columns=purchase_data["columns"], rows=purchase_data["rows"]
header_action = Div(
children=[
A(
url_name="games:add_session",
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
),
A(
href=reverse(
"games:list_sessions_start_session_from_session",
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.game.name,
children=[
Button(
icon=True,
color="gray",
size="xs",
children=[
Icon("play"),
truncate(f"{last_session.game.name}"),
],
)
],
),
)
if last_session
else "",
],
)
sessions_table = SimpleTable(
columns=session_data["columns"],
rows=session_data["rows"],
header_action=session_data["header_action"],
page_obj=session_page_obj,
elided_page_range=session_elided_page_range,
rows = [
[
NameWithIcon(session=session),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
session.duration_formatted_with_mark(),
ButtonGroup(
[
{
"href": reverse(
"games:list_sessions_end_session", args=[session.pk]
),
"slot": Icon("end"),
"title": "Finish session now",
"color": "green",
}
if session.timestamp_end is None
else {},
{
"href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_session", args=[session.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
),
]
for session in page_obj.object_list
]
table = SimpleTable(
columns=["Game", "Date", "Duration", "Actions"],
rows=rows,
header_action=header_action,
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
playevents_table = SimpleTable(
columns=playevent_data["columns"], rows=playevent_data["rows"]
return _game_section("Sessions", session_count, table, "No sessions yet.")
def _playevents_section(game: Game) -> SafeText:
playevents = game.playevents.all()
data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
table = SimpleTable(columns=data["columns"], rows=data["rows"])
return _game_section(
"Play Events", playevents.count(), table, "No play events yet."
)
history = Div(
def _history_section(game: Game) -> SafeText:
statuschanges = game.status_changes.all()
return Div(
[
("class", "mb-6"),
("id", "history-container"),
@@ -809,36 +798,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
("hx-swap", "outerHTML"),
],
[
H1(children=["History"], badge=statuschange_count),
H1(children=["History"], badge=statuschanges.count()),
_game_history(statuschanges),
],
)
_GET_SESSION_COUNT_SCRIPT = mark_safe(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
'.textContent.match("[0-9]+");\n'
" }\n"
" </script>"
)
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id)
content = Div(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
[
game_info,
_game_section(
"Purchases", purchase_count, purchases_table, "No purchases yet."
),
_game_section(
"Sessions", session_count, sessions_table, "No sessions yet."
),
_game_section(
"Play Events", playevent_count, playevents_table, "No play events yet."
),
history,
mark_safe(
"<script>\n"
" function getSessionCount() {\n"
" return document.getElementById('session-count')"
'.textContent.match("[0-9]+");\n'
" }\n"
" </script>"
),
_game_header(game, request, _game_overview_metrics(game)),
_purchases_section(game),
_sessions_section(game, request),
_playevents_section(game),
_history_section(game),
_GET_SESSION_COUNT_SCRIPT,
],
)
request.session["return_path"] = request.path
return render_page(
request,
+5 -1
View File
@@ -100,6 +100,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
filter_json = request.GET.get("filter", "")
if filter_json:
from games.filters import parse_purchase_filter
pf = parse_purchase_filter(filter_json)
if pf is not None:
purchases = purchases.filter(pf.to_q())
@@ -129,6 +130,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
request=request,
)
from common.components import PurchaseFilterBar, ModuleScript
filter_bar = PurchaseFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
@@ -139,7 +141,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
request,
content,
title="Manage purchases",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
+5 -1
View File
@@ -45,6 +45,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
filter_json = request.GET.get("filter", "")
if filter_json:
from games.filters import parse_session_filter
session_filter = parse_session_filter(filter_json)
if session_filter is not None:
sessions = sessions.filter(session_filter.to_q())
@@ -168,6 +169,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
request=request,
)
from common.components import SessionFilterBar
filter_json = request.GET.get("filter", "")
filter_bar = SessionFilterBar(
filter_json=filter_json,
@@ -179,7 +181,9 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
request,
content,
title="Manage sessions",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
scripts=ModuleScript("range_slider.js")
+ ModuleScript("selectable_filter.js")
+ ModuleScript("filter_bar.js"),
)
+8 -5
View File
@@ -176,7 +176,9 @@ def compute_stats(year: int | None = None) -> StatsData:
unique_days_percent = int(unique_days / 365 * 100)
# ── Spending ─────────────────────────────────────────────────────────────
total_spent = without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0
total_spent = (
without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0
)
without_refunded_count = without_refunded.count()
# ── Purchase breakdown ───────────────────────────────────────────────────
@@ -185,7 +187,10 @@ def compute_stats(year: int | None = None) -> StatsData:
without_refunded.filter(not_finished_q)
.filter(infinite=False)
.filter(only_games_and_dlc)
.filter(~Q(games__status=Game.Status.RETIRED) & ~Q(games__status=Game.Status.ABANDONED))
.filter(
~Q(games__status=Game.Status.RETIRED)
& ~Q(games__status=Game.Status.ABANDONED)
)
)
dropped = (
purchases.filter(not_finished_q)
@@ -270,9 +275,7 @@ def compute_stats(year: int | None = None) -> StatsData:
data: StatsData = {
"year": year_label,
"title": f"{year_label} Stats",
"total_hours": format_duration(
sessions.total_duration_unformatted(), "%2.0H"
),
"total_hours": format_duration(sessions.total_duration_unformatted(), "%2.0H"),
"total_sessions": sessions.count(),
"unique_days": unique_days,
"unique_days_percent": unique_days_percent,