purchases can now refer to multiple editions
Django CI/CD / test (push) Successful in 1m3s Details
Django CI/CD / build-and-push (push) Successful in 2m36s Details

allows purchases to be for more than one game
This commit is contained in:
Lukáš Kucharczyk 2025-01-08 21:00:19 +01:00
parent cd90d60475
commit c2853a3ecc
Signed by: lukas
SSH Key Fingerprint: SHA256:vMuSwvwAvcT6htVAioMP7rzzwMQNi3roESyhv+nAxeg
16 changed files with 308 additions and 84 deletions

View File

@ -9,6 +9,7 @@ from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate from common.utils import truncate
from games.models import Purchase
HTMLAttribute = tuple[str, str | int | bool] HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str HTMLTag = str
@ -71,9 +72,34 @@ def Popover(
) )
def PopoverTruncated(input_string: str, length: int = 30, ellipsis: str = "") -> str: def PopoverTruncated(
if (truncated := truncate(input_string, length, ellipsis)) != input_string: input_string: str,
return Popover(wrapped_content=truncated, popover_content=input_string) popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
The truncated text ends in `ellipsis`, and optionally
an always-visible `endpart` can be specified.
`popover_content` can be specified if:
1. It needs to be always displayed regardless if text is truncated.
2. It needs to differ from `input_string`.
"""
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
return Popover(
wrapped_content=truncated,
popover_content=popover_content if popover_content else input_string,
)
else:
if popover_content and popover_if_not_truncated:
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
else: else:
return input_string return input_string
@ -183,6 +209,47 @@ def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeTe
) )
def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
edition_count = purchase.editions.count()
popover_if_not_truncated = False
if edition_count == 1:
link_content += purchase.editions.first().name
popover_content = link_content
if edition_count > 1:
if purchase.name:
link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{edition_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{edition.name}</li>" for edition in purchase.editions.all())}
</ul>
"""
icon = purchase.platform.icon if edition_count == 1 else "unspecified"
if link_content == "":
raise ValueError("link_content is empty!!")
a_content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
icon,
[("title", "Multiple")],
),
PopoverTruncated(
input_string=link_content,
popover_content=mark_safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated,
),
],
)
return mark_safe(A(url=link, children=[a_content]))
def NameWithPlatformIcon(name: str, platform: str) -> SafeText: def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
content = Div( content = Div(
[("class", "inline-flex gap-2 items-center")], [("class", "inline-flex gap-2 items-center")],

View File

@ -34,7 +34,7 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
return obj return obj
def truncate(input_string: str, length: int = 30, ellipsis: str = "") -> str: def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return ( return (
(f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}") (f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length if len(input_string) > length
@ -42,6 +42,23 @@ def truncate(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
) )
def truncate(
input_string: str, length: int = 30, ellipsis: str = "", endpart: str = ""
) -> str:
max_content_length = length - len(endpart)
if max_content_length < 0:
raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length:
return f"{input_string[:max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return (
f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length
else f"{input_string[:length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
)
T = TypeVar("T", str, int, date) T = TypeVar("T", str, int, date)

View File

@ -16,7 +16,7 @@ class SessionForm(forms.ModelForm):
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name") # queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# ) # )
purchase = forms.ModelChoiceField( purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"), queryset=Purchase.objects.all(),
widget=forms.Select(attrs={"autofocus": "autofocus"}), widget=forms.Select(attrs={"autofocus": "autofocus"}),
) )
@ -38,12 +38,12 @@ class SessionForm(forms.ModelForm):
] ]
class EditionChoiceField(forms.ModelChoiceField): class EditionChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str: def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.Select): class IncludePlatformSelect(forms.SelectMultiple):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs) option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"): if platform_id := safe_getattr(value, "instance.platform.id"):
@ -58,7 +58,7 @@ class PurchaseForm(forms.ModelForm):
# Automatically update related_purchase <select/> # Automatically update related_purchase <select/>
# to only include purchases of the selected edition. # to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition") related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update( self.fields["editions"].widget.attrs.update(
{ {
"hx-trigger": "load, click", "hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url, "hx-get": related_purchase_by_edition_url,
@ -67,15 +67,13 @@ class PurchaseForm(forms.ModelForm):
} }
) )
edition = EditionChoiceField( editions = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField( related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by( queryset=Purchase.objects.filter(type=Purchase.GAME),
"edition__sort_name"
),
required=False, required=False,
) )
@ -88,7 +86,7 @@ class PurchaseForm(forms.ModelForm):
} }
model = Purchase model = Purchase
fields = [ fields = [
"edition", "editions",
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-07 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0041_purchase_converted_currency_purchase_converted_price_and_more'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='editions_temp',
field=models.ManyToManyField(blank=True, related_name='temp_purchases', to='games.edition'),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.1.3 on 2025-01-07 20:17
from django.db import migrations
def migrate_edition_to_editions_temp(apps, schema_editor):
Purchase = apps.get_model("games", "Purchase")
for purchase in Purchase.objects.all():
if purchase.edition:
print(
f"Migrating Purchase {purchase.id} with Edition {purchase.edition.id}"
)
purchase.editions_temp.add(purchase.edition)
print(purchase.editions_temp.all())
purchase.save()
else:
print(f"No edition found for Purchase {purchase.id}")
class Migration(migrations.Migration):
dependencies = [
("games", "0042_purchase_editions_temp"),
]
operations = [
migrations.RunPython(migrate_edition_to_editions_temp),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-07 20:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0043_auto_20250107_2117"),
]
operations = [
migrations.RemoveField(model_name="purchase", name="edition"),
migrations.RenameField(
model_name="purchase",
old_name="editions_temp",
new_name="editions",
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-07 20:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0044_auto_20250107_2132'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='editions',
field=models.ManyToManyField(blank=True, related_name='purchases', to='games.edition'),
),
]

View File

@ -113,7 +113,7 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager() objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey(Edition, on_delete=models.CASCADE) editions = models.ManyToManyField(Edition, related_name="purchases", blank=True)
platform = models.ForeignKey( platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
) )
@ -141,26 +141,28 @@ class Purchase(models.Model):
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@property
def first_edition(self):
return self.editions.first()
def __str__(self): def __str__(self):
additional_info = [ additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "", self.get_type_display() if self.type != Purchase.GAME else "",
( (
f"{self.edition.platform} version on {self.platform}" f"{self.first_edition.platform} version on {self.platform}"
if self.platform != self.edition.platform if self.platform != self.first_edition.platform
else self.platform else self.platform
), ),
self.edition.year_released, self.first_edition.year_released,
self.get_ownership_type_display(), self.get_ownership_type_display(),
] ]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})" return f"{self.first_edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type == Purchase.GAME: if self.type != Purchase.GAME and not self.related_purchase:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError( raise ValidationError(
f"{self.get_type_display()} must have a related purchase." f"{self.get_type_display()} must have a related purchase."
) )

View File

@ -1443,6 +1443,10 @@ input:checked + .toggle-bg {
margin-top: 1rem; margin-top: 1rem;
} }
.ml-4 {
margin-left: 1rem;
}
.block { .block {
display: block; display: block;
} }
@ -1471,6 +1475,10 @@ input:checked + .toggle-bg {
display: grid; display: grid;
} }
.list-item {
display: list-item;
}
.hidden { .hidden {
display: none; display: none;
} }
@ -1652,6 +1660,14 @@ input:checked + .toggle-bg {
resize: both; resize: both;
} }
.list-inside {
list-style-position: inside;
}
.list-disc {
list-style-type: disc;
}
.grid-cols-4 { .grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }

View File

@ -2,11 +2,11 @@
{% load static %} {% load static %}
{% partialdef purchase-name %} {% partialdef purchase-name %}
{% if purchase.type != 'game' %} {% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.edition.game.id> <c-gamelink :game_id=purchase.first_edition.game.id>
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }}) {{ purchase.name }} ({{ purchase.first_edition.edition.name }} {{ purchase.get_type_display }})
</c-gamelink> </c-gamelink>
{% else %} {% else %}
<c-gamelink :game_id=purchase.edition.game.id :name=purchase.edition.name /> <c-gamelink :game_id=purchase.first_edition.game.id :name=purchase.first_edition.name />
{% endif %} {% endif %}
{% endpartialdef %} {% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">

View File

@ -0,0 +1,34 @@
<c-layouts.base>
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex flex-col gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{% if purchase.name %}{{ purchase.name }}{% else %}Unnamed purchase{% endif %}</span> <span class="text-slate-500 text-2xl">({{ purchase.editions.count }} games)</span>
</span>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_purchase' purchase.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
Edit
</button>
</a>
<a href="{% url 'delete_purchase' purchase.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
Delete
</button>
</a>
</div>
<div>Price: {{ purchase.converted_price | floatformat }} {{ purchase.converted_currency }} ({{ purchase.price | floatformat }} {{ purchase.price_currency }})</div>
<div>
<h2 class="text-base">Items:</h2>
<ul class="list-disc list-inside">
{% for edition in purchase.editions.all %}
<li><c-gamelink :game_id=edition.game.id :name=edition.name /></li>
{% endfor %}
</ul>
</div>
</div>
</div>
</c-layouts.base>

View File

@ -54,6 +54,11 @@ urlpatterns = [
purchase.delete_purchase, purchase.delete_purchase,
name="delete_purchase", name="delete_purchase",
), ),
path(
"purchase/<int:purchase_id>/view",
purchase.view_purchase,
name="view_purchase",
),
path( path(
"purchase/<int:purchase_id>/finish", "purchase/<int:purchase_id>/finish",
purchase.finish_purchase, purchase.finish_purchase,

View File

@ -13,6 +13,7 @@ from common.components import (
Button, Button,
Div, Div,
Icon, Icon,
LinkedPurchase,
NameWithPlatformIcon, NameWithPlatformIcon,
Popover, Popover,
PopoverTruncated, PopoverTruncated,
@ -162,7 +163,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
to_attr="nongame_related_purchases", to_attr="nongame_related_purchases",
) )
game_purchases_prefetch: Prefetch[Purchase] = Prefetch( game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
"purchase_set", "purchases",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related( queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch nongame_related_purchases_prefetch
), ),
@ -174,14 +175,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
.order_by("year_released") .order_by("year_released")
) )
purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased") purchases = Purchase.objects.filter(editions__game=game).order_by("date_purchased")
sessions = Session.objects.prefetch_related("device").filter( sessions = Session.objects.prefetch_related("device").filter(
purchase__edition__game=game purchase__editions__game=game
) )
session_count = sessions.count() session_count = sessions.count()
session_count_without_manual = ( session_count_without_manual = (
Session.objects.without_manual().filter(purchase__edition__game=game).count() Session.objects.without_manual().filter(purchase__editions__game=game).count()
) )
if sessions: if sessions:
@ -242,10 +243,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"columns": ["Name", "Type", "Date", "Price", "Actions"], "columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [ "rows": [
[ [
NameWithPlatformIcon( LinkedPurchase(purchase),
name=purchase.name if purchase.name else purchase.edition.name,
platform=purchase.platform,
),
purchase.get_type_display(), purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat), purchase.date_purchased.strftime(dateformat),
PurchasePrice(purchase), PurchasePrice(purchase),
@ -271,7 +269,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
], ],
} }
sessions_all = Session.objects.filter(purchase__edition__game=game).order_by( sessions_all = Session.objects.filter(purchase__editions__game=game).order_by(
"-timestamp_start" "-timestamp_start"
) )
last_session = None last_session = None
@ -300,7 +298,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
args=[last_session.pk], args=[last_session.pk],
), ),
children=Popover( children=Popover(
popover_content=last_session.purchase.edition.name, popover_content=last_session.purchase.first_edition.name,
children=[ children=[
Button( Button(
icon=True, icon=True,
@ -308,7 +306,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
size="xs", size="xs",
children=[ children=[
Icon("play"), Icon("play"),
truncate(f"{last_session.purchase.edition.name}"), truncate(
f"{last_session.purchase.first_edition.name}"
),
], ],
) )
], ],
@ -324,7 +324,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
NameWithPlatformIcon( NameWithPlatformIcon(
name=session.purchase.name name=session.purchase.name
if session.purchase.name if session.purchase.name
else session.purchase.edition.name, else session.purchase.first_edition.name,
platform=session.purchase.platform, platform=session.purchase.platform,
), ),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
@ -375,7 +375,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"editions": editions, "editions": editions,
"game": game, "game": game,
"playrange": playrange, "playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(), "purchase_count": Purchase.objects.filter(editions__game=game).count(),
"session_average_without_manual": round( "session_average_without_manual": round(
safe_division( safe_division(
total_hours_without_manual, int(session_count_without_manual) total_hours_without_manual, int(session_count_without_manual)

View File

@ -2,7 +2,7 @@ from datetime import datetime
from typing import Any, Callable from typing import Any, Callable
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
from django.db.models.functions import TruncDate, TruncMonth from django.db.models.functions import TruncDate, TruncMonth
from django.db.models.manager import BaseManager from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
@ -49,7 +49,9 @@ def use_custom_redirect(
@login_required @login_required
def stats_alltime(request: HttpRequest) -> HttpResponse: def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime" year = "Alltime"
this_year_sessions = Session.objects.all().select_related("purchase__edition") this_year_sessions = Session.objects.all().prefetch_related(
Prefetch("purchase__editions")
)
this_year_sessions_with_durations = this_year_sessions.annotate( this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper( duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), F("timestamp_end") - F("timestamp_start"),
@ -58,10 +60,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
) )
longest_session = this_year_sessions_with_durations.order_by("-duration").first() longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter( this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions editions__purchase__session__in=this_year_sessions
).distinct() ).distinct()
this_year_games_with_session_counts = this_year_games.annotate( this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("edition__purchase__session"), session_count=Count("editions__purchase__session"),
) )
game_highest_session_count = this_year_games_with_session_counts.order_by( game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count" "-session_count"
@ -78,7 +80,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
).distinct() ).distinct()
this_year_purchases = Purchase.objects.all() this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("edition") this_year_purchases_with_currency = this_year_purchases.select_related("editions")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None date_refunded=None
) )
@ -127,11 +129,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions) Game.objects.filter(editions__purchase__session__in=this_year_sessions)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("edition__purchase__session__duration_calculated") F("editions__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual") + F("editions__purchase__session__duration_manual")
) )
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
@ -146,9 +148,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H") month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions) Game.objects.filter(editions__purchase__session__in=this_year_sessions)
.annotate( .annotate(
session_average=Avg("edition__purchase__session__duration_calculated") session_average=Avg("editions__purchase__session__duration_calculated")
) )
.order_by("-session_average") .order_by("-session_average")
.first() .first()
@ -175,10 +177,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
last_play_date = "N/A" last_play_date = "N/A"
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game first_play_game = first_session.purchase.first_edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat) first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest() last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game last_play_game = last_session.purchase.first_edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -227,7 +229,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
else 0 else 0
), ),
"longest_session_game": ( "longest_session_game": (
longest_session.purchase.edition.game if longest_session else None longest_session.purchase.first_edition.game if longest_session else None
), ),
"highest_session_count": ( "highest_session_count": (
game_highest_session_count.session_count game_highest_session_count.session_count
@ -266,7 +268,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("stats_alltime")) return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter( this_year_sessions = Session.objects.filter(
timestamp_start__year=year timestamp_start__year=year
).select_related("purchase__edition") ).prefetch_related("purchase__editions")
this_year_sessions_with_durations = this_year_sessions.annotate( this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper( duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), F("timestamp_end") - F("timestamp_start"),
@ -275,12 +277,12 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
) )
longest_session = this_year_sessions_with_durations.order_by("-duration").first() longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter( this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions edition__purchases__session__in=this_year_sessions
).distinct() ).distinct()
this_year_games_with_session_counts = this_year_games.annotate( this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count( session_count=Count(
"edition__purchase__session", "edition__purchases__session",
filter=Q(edition__purchase__session__timestamp_start__year=year), filter=Q(edition__purchases__session__timestamp_start__year=year),
) )
) )
game_highest_session_count = this_year_games_with_session_counts.order_by( game_highest_session_count = this_year_games_with_session_counts.order_by(
@ -298,7 +300,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
).distinct() ).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year) this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.select_related("edition") this_year_purchases_with_currency = this_year_purchases.prefetch_related("editions")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None date_refunded=None
).exclude(ownership_type=Purchase.DEMO) ).exclude(ownership_type=Purchase.DEMO)
@ -335,7 +337,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = ( purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by( purchases_finished_this_year.filter(editions__year_released=year).order_by(
"date_finished" "date_finished"
) )
) )
@ -349,11 +351,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions) Game.objects.filter(edition__purchases__session__in=this_year_sessions)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("edition__purchase__session__duration_calculated") F("edition__purchases__session__duration_calculated")
+ F("edition__purchase__session__duration_manual") + F("edition__purchases__session__duration_manual")
) )
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
@ -368,9 +370,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H") month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions) Game.objects.filter(edition__purchases__session__in=this_year_sessions)
.annotate( .annotate(
session_average=Avg("edition__purchase__session__duration_calculated") session_average=Avg("edition__purchases__session__duration_calculated")
) )
.order_by("-session_average") .order_by("-session_average")
.first() .first()
@ -401,10 +403,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = None last_play_game = None
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game first_play_game = first_session.purchase.first_edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat) first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest() last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game last_play_game = last_session.purchase.first_edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -421,7 +423,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"total_games": this_year_played_purchases.count(), "total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter( "total_2023_games": this_year_played_purchases.filter(
edition__year_released=year editions__year_released=year
).count(), ).count(),
"top_10_games_by_playtime": top_10_games_by_playtime, "top_10_games_by_playtime": top_10_games_by_playtime,
"year": year, "year": year,
@ -432,16 +434,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"spent_per_game": int( "spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count) safe_division(total_spent, this_year_purchases_without_refunded_count)
), ),
"all_finished_this_year": purchases_finished_this_year.select_related( "all_finished_this_year": purchases_finished_this_year.prefetch_related(
"edition" "editions"
).order_by("date_finished"), ).order_by("date_finished"),
"all_finished_this_year_count": purchases_finished_this_year.count(), "all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related( "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"edition" "editions"
).order_by("date_finished"), ).order_by("date_finished"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"edition" "editions"
).order_by("date_finished"), ).order_by("date_finished"),
"total_sessions": this_year_sessions.count(), "total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"], "unique_days": unique_days["dates"],
@ -471,7 +473,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
else 0 else 0
), ),
"longest_session_game": ( "longest_session_game": (
longest_session.purchase.edition.game if longest_session else None longest_session.purchase.first_edition.game if longest_session else None
), ),
"highest_session_count": ( "highest_session_count": (
game_highest_session_count.session_count game_highest_session_count.session_count

View File

@ -13,7 +13,7 @@ from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from common.components import A, Button, Icon, LinkedNameWithPlatformIcon, PurchasePrice from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
from common.time import dateformat from common.time import dateformat
from games.forms import PurchaseForm from games.forms import PurchaseForm
from games.models import Edition, Purchase from games.models import Edition, Purchase
@ -58,11 +58,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
], ],
"rows": [ "rows": [
[ [
LinkedNameWithPlatformIcon( LinkedPurchase(purchase),
name=purchase.edition.name,
game_id=purchase.edition.game.pk,
platform=purchase.platform,
),
purchase.get_type_display(), purchase.get_type_display(),
PurchasePrice(purchase), PurchasePrice(purchase),
purchase.infinite, purchase.infinite,
@ -173,7 +169,7 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
context["script_name"] = "add_purchase.js" # context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@ -189,7 +185,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
context["purchase_id"] = str(purchase_id) context["purchase_id"] = str(purchase_id)
context["script_name"] = "add_purchase.js" # context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@ -200,6 +196,12 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("list_purchases") return redirect("list_purchases")
@login_required
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
return render(request, "view_purchase.html", {"purchase": purchase})
@login_required @login_required
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)

View File

@ -97,7 +97,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
args=[last_session.pk], args=[last_session.pk],
), ),
children=Popover( children=Popover(
popover_content=last_session.purchase.edition.name, popover_content=last_session.purchase.first_edition.name,
children=[ children=[
Button( Button(
icon=True, icon=True,
@ -106,7 +106,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=[ children=[
Icon("play"), Icon("play"),
truncate( truncate(
f"{last_session.purchase.edition.name}" f"{last_session.purchase.first_edition.name}"
), ),
], ],
) )
@ -131,8 +131,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
"rows": [ "rows": [
[ [
LinkedNameWithPlatformIcon( LinkedNameWithPlatformIcon(
name=session.purchase.edition.name, name=session.purchase.first_edition.name,
game_id=session.purchase.edition.game.pk, game_id=session.purchase.first_edition.game.pk,
platform=session.purchase.platform, platform=session.purchase.platform,
), ),
f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", f"{local_strftime(session.timestamp_start)}{f"{local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",