purchases can now refer to multiple editions
allows purchases to be for more than one game
This commit is contained in:
		@ -9,6 +9,7 @@ from django.urls import NoReverseMatch, reverse
 | 
			
		||||
from django.utils.safestring import SafeText, mark_safe
 | 
			
		||||
 | 
			
		||||
from common.utils import truncate
 | 
			
		||||
from games.models import Purchase
 | 
			
		||||
 | 
			
		||||
HTMLAttribute = tuple[str, str | int | bool]
 | 
			
		||||
HTMLTag = str
 | 
			
		||||
@ -71,11 +72,36 @@ def Popover(
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def PopoverTruncated(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
 | 
			
		||||
    if (truncated := truncate(input_string, length, ellipsis)) != input_string:
 | 
			
		||||
        return Popover(wrapped_content=truncated, popover_content=input_string)
 | 
			
		||||
def PopoverTruncated(
 | 
			
		||||
    input_string: str,
 | 
			
		||||
    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:
 | 
			
		||||
        return input_string
 | 
			
		||||
        if popover_content and popover_if_not_truncated:
 | 
			
		||||
            return Popover(
 | 
			
		||||
                wrapped_content=input_string,
 | 
			
		||||
                popover_content=popover_content if popover_content else "",
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            return input_string
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def A(
 | 
			
		||||
@ -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:
 | 
			
		||||
    content = Div(
 | 
			
		||||
        [("class", "inline-flex gap-2 items-center")],
 | 
			
		||||
 | 
			
		||||
@ -34,7 +34,7 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
 | 
			
		||||
    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 (
 | 
			
		||||
        (f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}")
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ class SessionForm(forms.ModelForm):
 | 
			
		||||
    #     queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
 | 
			
		||||
    # )
 | 
			
		||||
    purchase = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Purchase.objects.order_by("edition__sort_name"),
 | 
			
		||||
        queryset=Purchase.objects.all(),
 | 
			
		||||
        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:
 | 
			
		||||
        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):
 | 
			
		||||
        option = super().create_option(name, value, *args, **kwargs)
 | 
			
		||||
        if platform_id := safe_getattr(value, "instance.platform.id"):
 | 
			
		||||
@ -58,7 +58,7 @@ class PurchaseForm(forms.ModelForm):
 | 
			
		||||
        # Automatically update related_purchase <select/>
 | 
			
		||||
        # to only include purchases of the selected 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-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"),
 | 
			
		||||
        widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
 | 
			
		||||
    )
 | 
			
		||||
    platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
 | 
			
		||||
    related_purchase = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
 | 
			
		||||
            "edition__sort_name"
 | 
			
		||||
        ),
 | 
			
		||||
        queryset=Purchase.objects.filter(type=Purchase.GAME),
 | 
			
		||||
        required=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@ -88,7 +86,7 @@ class PurchaseForm(forms.ModelForm):
 | 
			
		||||
        }
 | 
			
		||||
        model = Purchase
 | 
			
		||||
        fields = [
 | 
			
		||||
            "edition",
 | 
			
		||||
            "editions",
 | 
			
		||||
            "platform",
 | 
			
		||||
            "date_purchased",
 | 
			
		||||
            "date_refunded",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0042_purchase_editions_temp.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0042_purchase_editions_temp.py
									
									
									
									
									
										Normal 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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										27
									
								
								games/migrations/0043_auto_20250107_2117.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								games/migrations/0043_auto_20250107_2117.py
									
									
									
									
									
										Normal 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),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0044_auto_20250107_2132.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0044_auto_20250107_2132.py
									
									
									
									
									
										Normal 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",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0045_alter_purchase_editions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0045_alter_purchase_editions.py
									
									
									
									
									
										Normal 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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -113,7 +113,7 @@ class Purchase(models.Model):
 | 
			
		||||
 | 
			
		||||
    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, 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)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def first_edition(self):
 | 
			
		||||
        return self.editions.first()
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        additional_info = [
 | 
			
		||||
            self.get_type_display() if self.type != Purchase.GAME else "",
 | 
			
		||||
            (
 | 
			
		||||
                f"{self.edition.platform} version on {self.platform}"
 | 
			
		||||
                if self.platform != self.edition.platform
 | 
			
		||||
                f"{self.first_edition.platform} version on {self.platform}"
 | 
			
		||||
                if self.platform != self.first_edition.platform
 | 
			
		||||
                else self.platform
 | 
			
		||||
            ),
 | 
			
		||||
            self.edition.year_released,
 | 
			
		||||
            self.first_edition.year_released,
 | 
			
		||||
            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):
 | 
			
		||||
        return self.type == self.GAME
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if self.type == Purchase.GAME:
 | 
			
		||||
            self.name = ""
 | 
			
		||||
        elif self.type != Purchase.GAME and not self.related_purchase:
 | 
			
		||||
        if self.type != Purchase.GAME and not self.related_purchase:
 | 
			
		||||
            raise ValidationError(
 | 
			
		||||
                f"{self.get_type_display()} must have a related purchase."
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
@ -1443,6 +1443,10 @@ input:checked + .toggle-bg {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ml-4 {
 | 
			
		||||
  margin-left: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.block {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
@ -1471,6 +1475,10 @@ input:checked + .toggle-bg {
 | 
			
		||||
  display: grid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.list-item {
 | 
			
		||||
  display: list-item;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hidden {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
@ -1652,6 +1660,14 @@ input:checked + .toggle-bg {
 | 
			
		||||
  resize: both;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.list-inside {
 | 
			
		||||
  list-style-position: inside;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.list-disc {
 | 
			
		||||
  list-style-type: disc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.grid-cols-4 {
 | 
			
		||||
  grid-template-columns: repeat(4, minmax(0, 1fr));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,11 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% partialdef purchase-name %}
 | 
			
		||||
{% if purchase.type != 'game' %}
 | 
			
		||||
    <c-gamelink :game_id=purchase.edition.game.id>
 | 
			
		||||
    {{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
 | 
			
		||||
    <c-gamelink :game_id=purchase.first_edition.game.id>
 | 
			
		||||
    {{ purchase.name }} ({{ purchase.first_edition.edition.name }} {{ purchase.get_type_display }})
 | 
			
		||||
    </c-gamelink>
 | 
			
		||||
{% 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 %}
 | 
			
		||||
{% endpartialdef %}
 | 
			
		||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										34
									
								
								games/templates/view_purchase.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								games/templates/view_purchase.html
									
									
									
									
									
										Normal 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>
 | 
			
		||||
@ -54,6 +54,11 @@ urlpatterns = [
 | 
			
		||||
        purchase.delete_purchase,
 | 
			
		||||
        name="delete_purchase",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "purchase/<int:purchase_id>/view",
 | 
			
		||||
        purchase.view_purchase,
 | 
			
		||||
        name="view_purchase",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "purchase/<int:purchase_id>/finish",
 | 
			
		||||
        purchase.finish_purchase,
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,7 @@ from common.components import (
 | 
			
		||||
    Button,
 | 
			
		||||
    Div,
 | 
			
		||||
    Icon,
 | 
			
		||||
    LinkedPurchase,
 | 
			
		||||
    NameWithPlatformIcon,
 | 
			
		||||
    Popover,
 | 
			
		||||
    PopoverTruncated,
 | 
			
		||||
@ -162,7 +163,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        to_attr="nongame_related_purchases",
 | 
			
		||||
    )
 | 
			
		||||
    game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
 | 
			
		||||
        "purchase_set",
 | 
			
		||||
        "purchases",
 | 
			
		||||
        queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
 | 
			
		||||
            nongame_related_purchases_prefetch
 | 
			
		||||
        ),
 | 
			
		||||
@ -174,14 +175,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        .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(
 | 
			
		||||
        purchase__edition__game=game
 | 
			
		||||
        purchase__editions__game=game
 | 
			
		||||
    )
 | 
			
		||||
    session_count = sessions.count()
 | 
			
		||||
    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:
 | 
			
		||||
@ -242,10 +243,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        "columns": ["Name", "Type", "Date", "Price", "Actions"],
 | 
			
		||||
        "rows": [
 | 
			
		||||
            [
 | 
			
		||||
                NameWithPlatformIcon(
 | 
			
		||||
                    name=purchase.name if purchase.name else purchase.edition.name,
 | 
			
		||||
                    platform=purchase.platform,
 | 
			
		||||
                ),
 | 
			
		||||
                LinkedPurchase(purchase),
 | 
			
		||||
                purchase.get_type_display(),
 | 
			
		||||
                purchase.date_purchased.strftime(dateformat),
 | 
			
		||||
                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"
 | 
			
		||||
    )
 | 
			
		||||
    last_session = None
 | 
			
		||||
@ -300,7 +298,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
                        args=[last_session.pk],
 | 
			
		||||
                    ),
 | 
			
		||||
                    children=Popover(
 | 
			
		||||
                        popover_content=last_session.purchase.edition.name,
 | 
			
		||||
                        popover_content=last_session.purchase.first_edition.name,
 | 
			
		||||
                        children=[
 | 
			
		||||
                            Button(
 | 
			
		||||
                                icon=True,
 | 
			
		||||
@ -308,7 +306,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
                                size="xs",
 | 
			
		||||
                                children=[
 | 
			
		||||
                                    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(
 | 
			
		||||
                    name=session.purchase.name
 | 
			
		||||
                    if session.purchase.name
 | 
			
		||||
                    else session.purchase.edition.name,
 | 
			
		||||
                    else session.purchase.first_edition.name,
 | 
			
		||||
                    platform=session.purchase.platform,
 | 
			
		||||
                ),
 | 
			
		||||
                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,
 | 
			
		||||
        "game": game,
 | 
			
		||||
        "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(
 | 
			
		||||
            safe_division(
 | 
			
		||||
                total_hours_without_manual, int(session_count_without_manual)
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ from datetime import datetime
 | 
			
		||||
from typing import Any, Callable
 | 
			
		||||
 | 
			
		||||
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.manager import BaseManager
 | 
			
		||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
 | 
			
		||||
@ -49,7 +49,9 @@ def use_custom_redirect(
 | 
			
		||||
@login_required
 | 
			
		||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    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(
 | 
			
		||||
        duration=ExpressionWrapper(
 | 
			
		||||
            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()
 | 
			
		||||
    this_year_games = Game.objects.filter(
 | 
			
		||||
        edition__purchase__session__in=this_year_sessions
 | 
			
		||||
        editions__purchase__session__in=this_year_sessions
 | 
			
		||||
    ).distinct()
 | 
			
		||||
    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(
 | 
			
		||||
        "-session_count"
 | 
			
		||||
@ -78,7 +80,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    ).distinct()
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
        date_refunded=None
 | 
			
		||||
    )
 | 
			
		||||
@ -127,11 +129,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    total_spent = this_year_spendings["total_spent"] or 0
 | 
			
		||||
 | 
			
		||||
    games_with_playtime = (
 | 
			
		||||
        Game.objects.filter(edition__purchase__session__in=this_year_sessions)
 | 
			
		||||
        Game.objects.filter(editions__purchase__session__in=this_year_sessions)
 | 
			
		||||
        .annotate(
 | 
			
		||||
            total_playtime=Sum(
 | 
			
		||||
                F("edition__purchase__session__duration_calculated")
 | 
			
		||||
                + F("edition__purchase__session__duration_manual")
 | 
			
		||||
                F("editions__purchase__session__duration_calculated")
 | 
			
		||||
                + F("editions__purchase__session__duration_manual")
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        .values("id", "name", "total_playtime")
 | 
			
		||||
@ -146,9 +148,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        month["playtime"] = format_duration(month["playtime"], "%2.0H")
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
            session_average=Avg("edition__purchase__session__duration_calculated")
 | 
			
		||||
            session_average=Avg("editions__purchase__session__duration_calculated")
 | 
			
		||||
        )
 | 
			
		||||
        .order_by("-session_average")
 | 
			
		||||
        .first()
 | 
			
		||||
@ -175,10 +177,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    last_play_date = "N/A"
 | 
			
		||||
    if this_year_sessions:
 | 
			
		||||
        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)
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
    all_purchased_this_year_count = this_year_purchases_with_currency.count()
 | 
			
		||||
@ -227,7 +229,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
            else 0
 | 
			
		||||
        ),
 | 
			
		||||
        "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": (
 | 
			
		||||
            game_highest_session_count.session_count
 | 
			
		||||
@ -266,7 +268,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
        return HttpResponseRedirect(reverse("stats_alltime"))
 | 
			
		||||
    this_year_sessions = Session.objects.filter(
 | 
			
		||||
        timestamp_start__year=year
 | 
			
		||||
    ).select_related("purchase__edition")
 | 
			
		||||
    ).prefetch_related("purchase__editions")
 | 
			
		||||
    this_year_sessions_with_durations = this_year_sessions.annotate(
 | 
			
		||||
        duration=ExpressionWrapper(
 | 
			
		||||
            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()
 | 
			
		||||
    this_year_games = Game.objects.filter(
 | 
			
		||||
        edition__purchase__session__in=this_year_sessions
 | 
			
		||||
        edition__purchases__session__in=this_year_sessions
 | 
			
		||||
    ).distinct()
 | 
			
		||||
    this_year_games_with_session_counts = this_year_games.annotate(
 | 
			
		||||
        session_count=Count(
 | 
			
		||||
            "edition__purchase__session",
 | 
			
		||||
            filter=Q(edition__purchase__session__timestamp_start__year=year),
 | 
			
		||||
            "edition__purchases__session",
 | 
			
		||||
            filter=Q(edition__purchases__session__timestamp_start__year=year),
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
    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()
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
        date_refunded=None
 | 
			
		||||
    ).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_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"
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
@ -349,11 +351,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
    total_spent = this_year_spendings["total_spent"] or 0
 | 
			
		||||
 | 
			
		||||
    games_with_playtime = (
 | 
			
		||||
        Game.objects.filter(edition__purchase__session__in=this_year_sessions)
 | 
			
		||||
        Game.objects.filter(edition__purchases__session__in=this_year_sessions)
 | 
			
		||||
        .annotate(
 | 
			
		||||
            total_playtime=Sum(
 | 
			
		||||
                F("edition__purchase__session__duration_calculated")
 | 
			
		||||
                + F("edition__purchase__session__duration_manual")
 | 
			
		||||
                F("edition__purchases__session__duration_calculated")
 | 
			
		||||
                + F("edition__purchases__session__duration_manual")
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        .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")
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
            session_average=Avg("edition__purchase__session__duration_calculated")
 | 
			
		||||
            session_average=Avg("edition__purchases__session__duration_calculated")
 | 
			
		||||
        )
 | 
			
		||||
        .order_by("-session_average")
 | 
			
		||||
        .first()
 | 
			
		||||
@ -401,10 +403,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
    last_play_game = None
 | 
			
		||||
    if this_year_sessions:
 | 
			
		||||
        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)
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
    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_2023_games": this_year_played_purchases.filter(
 | 
			
		||||
            edition__year_released=year
 | 
			
		||||
            editions__year_released=year
 | 
			
		||||
        ).count(),
 | 
			
		||||
        "top_10_games_by_playtime": top_10_games_by_playtime,
 | 
			
		||||
        "year": year,
 | 
			
		||||
@ -432,16 +434,16 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
        "spent_per_game": int(
 | 
			
		||||
            safe_division(total_spent, this_year_purchases_without_refunded_count)
 | 
			
		||||
        ),
 | 
			
		||||
        "all_finished_this_year": purchases_finished_this_year.select_related(
 | 
			
		||||
            "edition"
 | 
			
		||||
        "all_finished_this_year": purchases_finished_this_year.prefetch_related(
 | 
			
		||||
            "editions"
 | 
			
		||||
        ).order_by("date_finished"),
 | 
			
		||||
        "all_finished_this_year_count": purchases_finished_this_year.count(),
 | 
			
		||||
        "this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
 | 
			
		||||
            "edition"
 | 
			
		||||
        "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
 | 
			
		||||
            "editions"
 | 
			
		||||
        ).order_by("date_finished"),
 | 
			
		||||
        "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(
 | 
			
		||||
            "edition"
 | 
			
		||||
        "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
 | 
			
		||||
            "editions"
 | 
			
		||||
        ).order_by("date_finished"),
 | 
			
		||||
        "total_sessions": this_year_sessions.count(),
 | 
			
		||||
        "unique_days": unique_days["dates"],
 | 
			
		||||
@ -471,7 +473,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
            else 0
 | 
			
		||||
        ),
 | 
			
		||||
        "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": (
 | 
			
		||||
            game_highest_session_count.session_count
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@ from django.template.loader import render_to_string
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
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 games.forms import PurchaseForm
 | 
			
		||||
from games.models import Edition, Purchase
 | 
			
		||||
@ -58,11 +58,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
            ],
 | 
			
		||||
            "rows": [
 | 
			
		||||
                [
 | 
			
		||||
                    LinkedNameWithPlatformIcon(
 | 
			
		||||
                        name=purchase.edition.name,
 | 
			
		||||
                        game_id=purchase.edition.game.pk,
 | 
			
		||||
                        platform=purchase.platform,
 | 
			
		||||
                    ),
 | 
			
		||||
                    LinkedPurchase(purchase),
 | 
			
		||||
                    purchase.get_type_display(),
 | 
			
		||||
                    PurchasePrice(purchase),
 | 
			
		||||
                    purchase.infinite,
 | 
			
		||||
@ -173,7 +169,7 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
 | 
			
		||||
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    context["title"] = "Add New Purchase"
 | 
			
		||||
    context["script_name"] = "add_purchase.js"
 | 
			
		||||
    # context["script_name"] = "add_purchase.js"
 | 
			
		||||
    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["form"] = form
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -200,6 +196,12 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
 | 
			
		||||
    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
 | 
			
		||||
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
 | 
			
		||||
    purchase = get_object_or_404(Purchase, id=purchase_id)
 | 
			
		||||
 | 
			
		||||
@ -97,7 +97,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
 | 
			
		||||
                                    args=[last_session.pk],
 | 
			
		||||
                                ),
 | 
			
		||||
                                children=Popover(
 | 
			
		||||
                                    popover_content=last_session.purchase.edition.name,
 | 
			
		||||
                                    popover_content=last_session.purchase.first_edition.name,
 | 
			
		||||
                                    children=[
 | 
			
		||||
                                        Button(
 | 
			
		||||
                                            icon=True,
 | 
			
		||||
@ -106,7 +106,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
 | 
			
		||||
                                            children=[
 | 
			
		||||
                                                Icon("play"),
 | 
			
		||||
                                                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": [
 | 
			
		||||
                [
 | 
			
		||||
                    LinkedNameWithPlatformIcon(
 | 
			
		||||
                        name=session.purchase.edition.name,
 | 
			
		||||
                        game_id=session.purchase.edition.game.pk,
 | 
			
		||||
                        name=session.purchase.first_edition.name,
 | 
			
		||||
                        game_id=session.purchase.first_edition.game.pk,
 | 
			
		||||
                        platform=session.purchase.platform,
 | 
			
		||||
                    ),
 | 
			
		||||
                    f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user