Fix more code smells
This commit is contained in:
+3
-2
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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=""),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user