1 Commits

Author SHA1 Message Date
0c20d31d31 Initial commit
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-11-11 23:29:07 +01:00
23 changed files with 476 additions and 565 deletions

20
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,20 @@
repos:
# disable due to incomaptible formatting between
# black and ruff
# TODO: replace with ruff when it works on NixOS
# - repo: https://github.com/psf/black
# rev: 24.8.0
# hooks:
# - id: black
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
hooks:
- id: djlint-reformat-django
args: ["--ignore", "H011"]
- id: djlint-django
args: ["--ignore", "H011"]

View File

@ -3,13 +3,11 @@ from string import ascii_lowercase
from typing import Any, Callable
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
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
@ -52,7 +50,6 @@ def randomid(seed: str = "", length: int = 10) -> str:
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
@ -65,43 +62,17 @@ def Popover(
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
],
children=children,
template="cotton/popover.html",
)
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,
)
def PopoverTruncated(input_string: str) -> str:
if (truncated := truncate(input_string)) != input_string:
return Popover(wrapped_content=truncated, popover_content=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:
return input_string
return input_string
def A(
@ -154,14 +125,33 @@ def Div(
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
def Label(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
return Component(tag_name="label", attributes=attributes, children=children)
def Input(
type: str = "text",
label: str = "",
id: str = "",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
input_component = Component(
tag_name="input",
attributes=attributes + [("type", type), ("id", id)],
children=children,
)
if label != "":
if id == "":
raise ValueError("Label is set but element ID is missing.")
return Label(
attributes=[("for", id)], children=[label, input_component, *children]
)
else:
return input_component
def Form(
@ -177,6 +167,74 @@ def Form(
)
def Fieldset(
label: str = "",
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
if label != "":
children = [Label(children=[label, *children])]
return Component(tag_name="fieldset", attributes=attributes, children=children)
def RadioFieldset(name: str, label: str, radio_buttons: list[dict[str, str]]):
return Component(
tag_name="span",
children=[
Component(tag_name="legend", children=label),
Component(
tag_name="fieldset",
children=[
Component(
tag_name="label",
attributes=[
("for", f"{name}__{radio["value"]}"),
],
children=[
radio["label"],
Input(
type="radio",
attributes=[
("id", f"{name}__{radio["value"]}"),
("name", name),
("value", radio["value"]),
("onClick", radio.get("onclick", "")),
],
),
],
)
for radio in radio_buttons
],
),
],
)
def BooleanRadioFieldset(name: str, label: str):
return RadioFieldset(
name=name,
label=label,
radio_buttons=[
{"label": "True", "value": "true"},
{"label": "False", "value": "false"},
],
)
def SubmitButton(label: str):
return Input(type="submit", attributes=[("value", label)])
# RadioFieldset(
# name="filter__dropped",
# label="Dropped",
# radio_buttons=[
# {"label": "True", "value": "true"},
# {"label": "False", "value": "false"},
# ],
# )
def Icon(
name: str,
attributes: list[HTMLAttribute] = [],
@ -209,47 +267,6 @@ 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")],
@ -263,11 +280,3 @@ def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
)
return mark_safe(content)
def PurchasePrice(purchase) -> str:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
)

View File

@ -1,6 +1,9 @@
from datetime import date
from typing import Any, Generator, TypeVar
from django.apps import apps
from django.db.models import Model
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
@ -34,31 +37,14 @@ 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
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > 30
else input_string
)
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)
@ -81,3 +67,17 @@ def generate_split_ranges(
def format_float_or_int(number: int | float):
return int(number) if float(number).is_integer() else f"{number:03.2f}"
def get_model_by_string(app_label: str, model_name: str):
return apps.get_model(app_label, model_name)
def get_field(model: Model, field_name: str):
field = model._meta.get_field(field_name)
return field
def get_field_type(model: Model, field_name: str):
field = model._meta.get_field(field_name)
return type(field)

101
games/filters.py Normal file
View File

@ -0,0 +1,101 @@
from enum import Enum
from typing import Any
from django.db.models import (
BooleanField,
CharField,
FloatField,
IntegerField,
QuerySet,
TextField,
)
from django.http import HttpRequest
from common.components import *
from common.utils import get_field, get_model_by_string
filter_param_prefix = "f_"
class Modifier(Enum):
EQUALS = "__exact"
GT = "__gt"
LT = "__lt"
CONTAINS = "__contains"
REGEX = "__regex"
ISNULL = "__isnull"
BETWEEN = "__gt", "__lt"
def create_filter_form(model: str, fields: list[str]):
filter_model = get_model_by_string("games", model)
automatic_filter_form_parts = []
for field in fields:
html_field_name = f"{filter_param_prefix}{field}"
match get_field(filter_model, field):
case BooleanField():
automatic_filter_form_parts.append(
BooleanRadioFieldset(name=html_field_name, label=field)
)
case TextField():
pass
case CharField():
js = str
onclick_handler: js = """f_price_currency.disabled = true;"""
automatic_filter_form_parts.extend(
[
RadioFieldset(
name=f"{field}_switch",
label="Modifier",
radio_buttons=[
{
"label": "Equals",
"value": Modifier.EQUALS.value,
"onclick": onclick_handler,
},
{"label": "Contains", "value": Modifier.CONTAINS.value},
],
),
Input(
label=field,
id=html_field_name,
attributes=[
("name", html_field_name + str(Modifier.EQUALS.value))
],
),
]
)
case IntegerField():
pass
case FloatField():
html = Input(
label=field,
type="number",
id=html_field_name,
attributes=[("name", html_field_name)],
)
automatic_filter_form_parts.append(html)
case _:
print(f"Field type of {field} not handled yet.")
automatic_filter_form = Form(
children=[*automatic_filter_form_parts, SubmitButton("Apply")]
)
return automatic_filter_form
def apply_filters(request: HttpRequest, queryset: QuerySet[Any]):
for parameter in request.GET:
if parameter.startswith(filter_param_prefix):
field_name = parameter.removeprefix(filter_param_prefix)
field_value = request.GET.get(parameter)
if field_value == "":
continue
match field_value:
case "true":
field_value = True
case "false":
field_value = False
case _:
pass
queryset = queryset.filter(**{f"{field_name}": field_value})
return queryset

View File

@ -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.all(),
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
@ -38,12 +38,12 @@ class SessionForm(forms.ModelForm):
]
class EditionChoiceField(forms.ModelMultipleChoiceField):
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.SelectMultiple):
class IncludePlatformSelect(forms.Select):
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["editions"].widget.attrs.update(
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
@ -67,13 +67,15 @@ class PurchaseForm(forms.ModelForm):
}
)
editions = EditionChoiceField(
edition = 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),
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False,
)
@ -86,7 +88,7 @@ class PurchaseForm(forms.ModelForm):
}
model = Purchase
fields = [
"editions",
"edition",
"platform",
"date_purchased",
"date_refunded",

View File

@ -1,18 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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:
purchase.editions_temp.add(purchase.edition)
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

@ -1,18 +0,0 @@
# 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

@ -1,18 +0,0 @@
# 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

@ -1,28 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-08 20:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0045_alter_purchase_editions"),
]
operations = [
migrations.AddField(
model_name="game",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.platform",
),
),
migrations.AlterUniqueTogether(
name="game",
unique_together={("name", "platform", "year_released")},
),
]

View File

@ -1,61 +0,0 @@
# Generated by Django 5.1.3 on 2025-01-19 20:29
from django.db import connection, migrations, models
def recreate_games(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
Game = apps.get_model("games", "Game")
Purchase = apps.get_model("games", "Purchase")
with connection.cursor() as cursor:
print("Create table games_gametemp")
cursor.execute(
"CREATE TABLE games_gametemp AS SELECT * FROM games_game WHERE 1=0;"
)
for edition in Edition.objects.all():
print(f"Re-create edition with ID {edition.id}")
cursor.execute(
"""
INSERT INTO games_gametemp (
id, name, sort_name, year_released, platform_id, wikidata, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
[
edition.id, # Reuse the Edition ID
edition.name,
edition.sort_name,
edition.year_released,
edition.platform_id,
Game.objects.get(id=edition.game.id).wikidata,
edition.created_at,
],
)
print("Turn foreign keys off")
cursor.execute("PRAGMA foreign_keys = OFF;")
print("Drop table games_game")
cursor.execute("DROP TABLE games_game;")
print("Drop table games_edition")
cursor.execute("DROP TABLE games_edition;")
print("Rename table games_gametemp to games_game")
# cursor.execute("ALTER TABLE games_gametemp RENAME TO games_game;")
cursor.execute("CREATE TABLE games_game AS SELECT * FROM games_gametemp;")
class Migration(migrations.Migration):
dependencies = [
("games", "0046_game_platform_alter_game_unique_together"),
]
operations = [
migrations.RunPython(recreate_games),
migrations.AlterField(
model_name="purchase",
name="editions",
field=models.ManyToManyField(
blank=True, related_name="purchases", to="games.game"
),
),
]

View File

@ -9,6 +9,20 @@ from django.utils import timezone
from common.time import format_duration
class Game(models.Model):
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
@ -30,26 +44,6 @@ def get_sentinel_platform():
)[0]
class Game(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
platform = models.ForeignKey(
Platform, on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
@ -119,7 +113,7 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager()
editions = models.ManyToManyField(Game, related_name="purchases", blank=True)
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
@ -147,28 +141,26 @@ 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.first_edition.platform} version on {self.platform}"
if self.platform != self.first_edition.platform
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
else self.platform
),
self.first_edition.year_released,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.first_edition} ({', '.join(filter(None, map(str, additional_info)))})"
return f"{self.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 and not self.related_purchase:
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)

View File

@ -1652,14 +1652,6 @@ 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));
}
@ -2200,10 +2192,6 @@ input:checked + .toggle-bg {
text-decoration-color: #64748b;
}
.decoration-dotted {
text-decoration-style: dotted;
}
.opacity-0 {
opacity: 0;
}
@ -3129,10 +3117,6 @@ textarea:disabled:is(.dark *) {
flex-direction: row;
}
.md\:justify-between {
justify-content: space-between;
}
.md\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(2rem * var(--tw-space-x-reverse));
@ -3276,13 +3260,3 @@ textarea:disabled:is(.dark *) {
.\[\&_td\:last-child\]\:text-right td:last-child {
text-align: right;
}
@media not all and (min-width: 640px) {
.\[\&_td\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden td:not(:first-child):not(:last-child) {
display: none;
}
.\[\&_th\:not\(\:first-child\)\:not\(\:last-child\)\]\:max-sm\:hidden th:not(:first-child):not(:last-child) {
display: none;
}
}

View File

@ -1,10 +1,8 @@
<span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ wrapped_content|default:slot }}</span>
<span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span>
<div data-popover
id="{{ id }}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
<div class="px-3 py-2">{{ popover_content }}</div>
<div data-popper-arrow></div>
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
<span class="hidden decoration-dotted"></span>
</div>

View File

@ -7,20 +7,20 @@
{{ header_action }}
</c-table-header>
{% endif %}
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden">
<tbody class="dark:divide-y">
{% for row in rows %}<c-table-row :data=row />{% endfor %}
</tbody>
</table>
</div>
{% if page_obj and elided_page_range %}
<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
aria-label="Table navigation">
<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto"><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{% if page_obj.has_previous %}

View File

@ -2,11 +2,11 @@
{% load static %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.first_edition.game.id>
{{ purchase.name }} ({{ purchase.first_edition.edition.name }} {{ purchase.get_type_display }})
<c-gamelink :game_id=purchase.edition.game.id>
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
</c-gamelink>
{% else %}
<c-gamelink :game_id=purchase.first_edition.game.id :name=purchase.first_edition.name />
<c-gamelink :game_id=purchase.edition.game.id :name=purchase.edition.name />
{% endif %}
{% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
@ -100,7 +100,7 @@
{% endif %}
</tbody>
</table>
{% if month_playtimes %}
{% if month_playtime %}
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table">
<tbody>
@ -142,9 +142,7 @@
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
@ -255,7 +253,7 @@
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
@ -276,7 +274,7 @@
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}

View File

@ -3,7 +3,7 @@
<div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
<span class="font-bold font-serif">{{ game.name }}</span>&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>
</span>
</div>
<div class="flex gap-4 dark:text-slate-400 mb-3">

View File

@ -1,34 +0,0 @@
<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,11 +54,6 @@ 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,

View File

@ -13,11 +13,9 @@ from common.components import (
Button,
Div,
Icon,
LinkedPurchase,
NameWithPlatformIcon,
Popover,
PopoverTruncated,
PurchasePrice,
)
from common.time import (
dateformat,
@ -27,7 +25,7 @@ from common.time import (
local_strftime,
timeformat,
)
from common.utils import safe_division, truncate
from common.utils import format_float_or_int, safe_division, truncate
from games.forms import GameForm
from games.models import Edition, Game, Purchase, Session
from games.views.general import use_custom_redirect
@ -163,7 +161,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
to_attr="nongame_related_purchases",
)
game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
"purchases",
"purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
@ -175,14 +173,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
.order_by("year_released")
)
purchases = Purchase.objects.filter(editions__game=game).order_by("date_purchased")
purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased")
sessions = Session.objects.prefetch_related("device").filter(
purchase__editions__game=game
purchase__edition__game=game
)
session_count = sessions.count()
session_count_without_manual = (
Session.objects.without_manual().filter(purchase__editions__game=game).count()
Session.objects.without_manual().filter(purchase__edition__game=game).count()
)
if sessions:
@ -243,10 +241,13 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"columns": ["Name", "Type", "Date", "Price", "Actions"],
"rows": [
[
LinkedPurchase(purchase),
NameWithPlatformIcon(
name=purchase.name if purchase.name else purchase.edition.name,
platform=purchase.platform,
),
purchase.get_type_display(),
purchase.date_purchased.strftime(dateformat),
PurchasePrice(purchase),
f"{format_float_or_int(purchase.price)} {purchase.price_currency}",
render_to_string(
"cotton/button_group.html",
{
@ -269,7 +270,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
],
}
sessions_all = Session.objects.filter(purchase__editions__game=game).order_by(
sessions_all = Session.objects.filter(purchase__edition__game=game).order_by(
"-timestamp_start"
)
last_session = None
@ -298,7 +299,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.first_edition.name,
popover_content=last_session.purchase.edition.name,
children=[
Button(
icon=True,
@ -306,9 +307,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
size="xs",
children=[
Icon("play"),
truncate(
f"{last_session.purchase.first_edition.name}"
),
truncate(f"{last_session.purchase.edition.name}"),
],
)
],
@ -324,7 +323,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
NameWithPlatformIcon(
name=session.purchase.name
if session.purchase.name
else session.purchase.first_edition.name,
else session.purchase.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 +374,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(editions__game=game).count(),
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average_without_manual": round(
safe_division(
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 django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
from django.db.models import Avg, Count, ExpressionWrapper, F, 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,9 +49,7 @@ def use_custom_redirect(
@login_required
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().prefetch_related(
Prefetch("purchase__editions")
)
this_year_sessions = Session.objects.all().select_related("purchase__edition")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -60,10 +58,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
editions__purchase__session__in=this_year_sessions
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("editions__purchase__session"),
session_count=Count("edition__purchase__session"),
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
@ -80,7 +78,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
).distinct()
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("editions")
this_year_purchases_with_currency = this_year_purchases.select_related("edition")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
@ -129,11 +127,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions)
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("editions__purchase__session__duration_calculated")
+ F("editions__purchase__session__duration_manual")
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -148,9 +146,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(editions__purchase__session__in=this_year_sessions)
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("editions__purchase__session__duration_calculated")
session_average=Avg("edition__purchase__session__duration_calculated")
)
.order_by("-session_average")
.first()
@ -177,10 +175,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.first_edition.game
first_play_game = first_session.purchase.edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game
last_play_game = last_session.purchase.edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -229,7 +227,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
else 0
),
"longest_session_game": (
longest_session.purchase.first_edition.game if longest_session else None
longest_session.purchase.edition.game if longest_session else None
),
"highest_session_count": (
game_highest_session_count.session_count
@ -268,7 +266,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).prefetch_related("purchase__editions")
).select_related("purchase__edition")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
@ -277,12 +275,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__purchases__session__in=this_year_sessions
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"edition__purchases__session",
filter=Q(edition__purchases__session__timestamp_start__year=year),
"edition__purchase__session",
filter=Q(edition__purchase__session__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
@ -300,7 +298,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.prefetch_related("editions")
this_year_purchases_with_currency = this_year_purchases.select_related("edition")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
).exclude(ownership_type=Purchase.DEMO)
@ -337,7 +335,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(editions__year_released=year).order_by(
purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished"
)
)
@ -351,11 +349,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__purchases__session__in=this_year_sessions)
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchases__session__duration_calculated")
+ F("edition__purchases__session__duration_manual")
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
@ -370,9 +368,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__purchases__session__in=this_year_sessions)
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchases__session__duration_calculated")
session_average=Avg("edition__purchase__session__duration_calculated")
)
.order_by("-session_average")
.first()
@ -403,10 +401,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.first_edition.game
first_play_game = first_session.purchase.edition.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.first_edition.game
last_play_game = last_session.purchase.edition.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
@ -423,7 +421,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
editions__year_released=year
edition__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
@ -434,16 +432,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.prefetch_related(
"editions"
"all_finished_this_year": purchases_finished_this_year.select_related(
"edition"
).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.prefetch_related(
"editions"
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
"edition"
).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.prefetch_related(
"editions"
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related(
"edition"
).order_by("date_finished"),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
@ -473,7 +471,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
else 0
),
"longest_session_game": (
longest_session.purchase.first_edition.game if longest_session else None
longest_session.purchase.edition.game if longest_session else None
),
"highest_session_count": (
game_highest_session_count.session_count

View File

@ -13,8 +13,17 @@ 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, LinkedPurchase, PurchasePrice
from common.components import (
BooleanRadioFieldset,
Form,
Icon,
Input,
LinkedNameWithPlatformIcon,
SubmitButton,
)
from common.time import dateformat
from common.utils import format_float_or_int
from games.filters import apply_filters, create_filter_form
from games.forms import PurchaseForm
from games.models import Edition, Purchase
from games.views.general import use_custom_redirect
@ -26,114 +35,141 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
filter_form = create_filter_form(
"Purchase", ["infinite", "price", "price_currency"]
)
purchases = apply_filters(request, purchases)
test_form = Form(
children=[
BooleanRadioFieldset(
name="filter__infinite",
label="Infinite",
),
Input(
label="Original currency",
id="filter__price_currency",
attributes=[("name", "filter__price_currency")],
),
SubmitButton("Apply"),
]
)
page_obj = None
if int(limit) != 0:
paginator = Paginator(purchases, limit)
page_obj = paginator.get_page(page_number)
purchases = page_obj.object_list
context = {
"title": "Manage purchases",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"header_action": A([], Button([], "Add purchase"), url="add_purchase"),
"columns": [
"Name",
"Type",
"Price",
"Infinite",
"Purchased",
"Refunded",
"Finished",
"Dropped",
"Created",
"Actions",
],
"rows": [
[
LinkedPurchase(purchase),
purchase.get_type_display(),
PurchasePrice(purchase),
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse(
"finish_purchase", args=[purchase.pk]
),
"slot": Icon("checkmark"),
"title": "Mark as finished",
}
if not purchase.date_finished
else {},
{
"href": reverse(
"drop_purchase", args=[purchase.pk]
),
"slot": Icon("eject"),
"title": "Mark as dropped",
}
if not purchase.date_dropped
else {},
{
"href": reverse(
"refund_purchase", args=[purchase.pk]
),
"slot": Icon("refund"),
"title": "Mark as refunded",
}
if not purchase.date_refunded
else {},
{
"href": reverse(
"edit_purchase", args=[purchase.pk]
),
"slot": Icon("edit"),
"title": "Edit",
"color": "gray",
},
{
"href": reverse(
"delete_purchase", args=[purchase.pk]
),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
},
}
context = {
"title": "Manage purchases",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"header_action": filter_form,
"columns": [
"Name",
"Type",
"Price",
"Currency",
"Infinite",
"Purchased",
"Refunded",
"Finished",
"Dropped",
"Created",
"Actions",
],
"rows": [
[
LinkedNameWithPlatformIcon(
name=purchase.edition.name,
game_id=purchase.edition.game.pk,
platform=purchase.platform,
),
purchase.get_type_display(),
format_float_or_int(purchase.price),
purchase.price_currency,
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse(
"finish_purchase", args=[purchase.pk]
),
"slot": Icon("checkmark"),
"title": "Mark as finished",
}
if not purchase.date_finished
else {},
{
"href": reverse(
"drop_purchase", args=[purchase.pk]
),
"slot": Icon("eject"),
"title": "Mark as dropped",
}
if not purchase.date_dropped
else {},
{
"href": reverse(
"refund_purchase", args=[purchase.pk]
),
"slot": Icon("refund"),
"title": "Mark as refunded",
}
if not purchase.date_refunded
else {},
{
"href": reverse(
"edit_purchase", args=[purchase.pk]
),
"slot": Icon("edit"),
"title": "Edit",
"color": "gray",
},
{
"href": reverse(
"delete_purchase", args=[purchase.pk]
),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
},
}
return render(request, "list_purchases.html", context)
@ -169,7 +205,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)
@ -185,7 +221,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)
@ -196,12 +232,6 @@ 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)

View File

@ -47,10 +47,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
| Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string)
)
try:
last_session = sessions.latest()
except Session.DoesNotExist:
last_session = None
last_session = sessions.latest()
page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
@ -97,7 +94,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
args=[last_session.pk],
),
children=Popover(
popover_content=last_session.purchase.first_edition.name,
popover_content=last_session.purchase.edition.name,
children=[
Button(
icon=True,
@ -106,15 +103,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
children=[
Icon("play"),
truncate(
f"{last_session.purchase.first_edition.name}"
f"{last_session.purchase.edition.name}"
),
],
)
],
),
)
if last_session
else "",
),
]
),
],
@ -131,8 +126,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
"rows": [
[
LinkedNameWithPlatformIcon(
name=session.purchase.first_edition.name,
game_id=session.purchase.first_edition.game.pk,
name=session.purchase.edition.name,
game_id=session.purchase.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 ""}",