Compare commits
	
		
			1 Commits
		
	
	
		
			64cce8a048
			...
			filters_fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						0c20d31d31
	
				 | 
					
					
						
							
								
								
									
										20
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal 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"]
 | 
			
		||||
@ -8,7 +8,6 @@
 | 
			
		||||
* Add all-time stats
 | 
			
		||||
* Manage purchases
 | 
			
		||||
* Automatically convert purchase prices
 | 
			
		||||
* Add emulated property to sessions
 | 
			
		||||
 | 
			
		||||
## Improved
 | 
			
		||||
* mark refunded purchases red on game overview
 | 
			
		||||
 | 
			
		||||
@ -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 Game, Purchase, Session
 | 
			
		||||
 | 
			
		||||
HTMLAttribute = tuple[str, str | int | bool]
 | 
			
		||||
HTMLTag = str
 | 
			
		||||
@ -32,7 +30,7 @@ def Component(
 | 
			
		||||
        attributesList = [f'{name}="{value}"' for name, value in attributes]
 | 
			
		||||
        # make attribute list into a string
 | 
			
		||||
        # and insert space between tag and attribute list
 | 
			
		||||
        attributesBlob = f" {' '.join(attributesList)}"
 | 
			
		||||
        attributesBlob = f" {" ".join(attributesList)}"
 | 
			
		||||
    tag: str = ""
 | 
			
		||||
    if tag_name != "":
 | 
			
		||||
        tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
 | 
			
		||||
@ -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] = [],
 | 
			
		||||
@ -188,83 +246,15 @@ def Icon(
 | 
			
		||||
    return result
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
 | 
			
		||||
    link = reverse("view_purchase", args=[int(purchase.id)])
 | 
			
		||||
    link_content = ""
 | 
			
		||||
    popover_content = ""
 | 
			
		||||
    game_count = purchase.games.count()
 | 
			
		||||
    popover_if_not_truncated = False
 | 
			
		||||
    if game_count == 1:
 | 
			
		||||
        link_content += purchase.games.first().name
 | 
			
		||||
        popover_content = link_content
 | 
			
		||||
    if game_count > 1:
 | 
			
		||||
        if purchase.name:
 | 
			
		||||
            link_content += f"{purchase.name}"
 | 
			
		||||
            popover_content += f"<h1>{purchase.name}</h1><br>"
 | 
			
		||||
        else:
 | 
			
		||||
            link_content += f"{game_count} games"
 | 
			
		||||
            popover_if_not_truncated = True
 | 
			
		||||
        popover_content += f"""
 | 
			
		||||
        <ul class="list-disc list-inside">
 | 
			
		||||
            {"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
 | 
			
		||||
        </ul>
 | 
			
		||||
        """
 | 
			
		||||
    icon = purchase.platform.icon if game_count == 1 else "unspecified"
 | 
			
		||||
    if link_content == "":
 | 
			
		||||
        raise ValueError("link_content is empty!!")
 | 
			
		||||
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
 | 
			
		||||
    link = reverse("view_game", args=[int(game_id)])
 | 
			
		||||
    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 NameWithIcon(
 | 
			
		||||
    name: str = "",
 | 
			
		||||
    platform: str = "",
 | 
			
		||||
    game_id: int = 0,
 | 
			
		||||
    session_id: int = 0,
 | 
			
		||||
    purchase_id: int = 0,
 | 
			
		||||
    linkify: bool = True,
 | 
			
		||||
    emulated: bool = False,
 | 
			
		||||
) -> SafeText:
 | 
			
		||||
    create_link = False
 | 
			
		||||
    link = ""
 | 
			
		||||
    platform = None
 | 
			
		||||
    if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
 | 
			
		||||
        create_link = True
 | 
			
		||||
        if session_id:
 | 
			
		||||
            session = Session.objects.get(pk=session_id)
 | 
			
		||||
            emulated = session.emulated
 | 
			
		||||
            game_id = session.game.pk
 | 
			
		||||
        if purchase_id:
 | 
			
		||||
            purchase = Purchase.objects.get(pk=purchase_id)
 | 
			
		||||
            game_id = purchase.games.first().pk
 | 
			
		||||
        if game_id:
 | 
			
		||||
            game = Game.objects.get(pk=game_id)
 | 
			
		||||
        name = game.name
 | 
			
		||||
        platform = game.platform
 | 
			
		||||
        link = reverse("view_game", args=[int(game_id)])
 | 
			
		||||
    content = Div(
 | 
			
		||||
        [("class", "inline-flex gap-2 items-center")],
 | 
			
		||||
        [
 | 
			
		||||
            Icon(
 | 
			
		||||
                platform.icon,
 | 
			
		||||
                [("title", platform.name)],
 | 
			
		||||
            )
 | 
			
		||||
            if platform
 | 
			
		||||
            else "",
 | 
			
		||||
            Icon("emulated", [("title", "Emulated")]) if emulated else "",
 | 
			
		||||
            ),
 | 
			
		||||
            PopoverTruncated(name),
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
@ -272,16 +262,21 @@ def NameWithIcon(
 | 
			
		||||
    return mark_safe(
 | 
			
		||||
        A(
 | 
			
		||||
            url=link,
 | 
			
		||||
            children=[content],
 | 
			
		||||
        )
 | 
			
		||||
        if create_link
 | 
			
		||||
        else content,
 | 
			
		||||
            children=[a_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",
 | 
			
		||||
def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
 | 
			
		||||
    content = Div(
 | 
			
		||||
        [("class", "inline-flex gap-2 items-center")],
 | 
			
		||||
        [
 | 
			
		||||
            Icon(
 | 
			
		||||
                platform.icon,
 | 
			
		||||
                [("title", platform.name)],
 | 
			
		||||
            ),
 | 
			
		||||
            PopoverTruncated(name),
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return mark_safe(content)
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from games.models import (
 | 
			
		||||
    Device,
 | 
			
		||||
    Edition,
 | 
			
		||||
    ExchangeRate,
 | 
			
		||||
    Game,
 | 
			
		||||
    Platform,
 | 
			
		||||
@ -14,5 +15,6 @@ admin.site.register(Game)
 | 
			
		||||
admin.site.register(Purchase)
 | 
			
		||||
admin.site.register(Platform)
 | 
			
		||||
admin.site.register(Session)
 | 
			
		||||
admin.site.register(Edition)
 | 
			
		||||
admin.site.register(Device)
 | 
			
		||||
admin.site.register(ExchangeRate)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										101
									
								
								games/filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								games/filters.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -2,7 +2,7 @@ from django import forms
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from common.utils import safe_getattr
 | 
			
		||||
from games.models import Device, Game, Platform, Purchase, Session
 | 
			
		||||
from games.models import Device, Edition, Game, Platform, Purchase, Session
 | 
			
		||||
 | 
			
		||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
 | 
			
		||||
custom_datetime_widget = forms.DateTimeInput(
 | 
			
		||||
@ -12,8 +12,11 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SessionForm(forms.ModelForm):
 | 
			
		||||
    game = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Game.objects.order_by("sort_name"),
 | 
			
		||||
    # purchase = forms.ModelChoiceField(
 | 
			
		||||
    #     queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
 | 
			
		||||
    # )
 | 
			
		||||
    purchase = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Purchase.objects.order_by("edition__sort_name"),
 | 
			
		||||
        widget=forms.Select(attrs={"autofocus": "autofocus"}),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@ -26,22 +29,21 @@ class SessionForm(forms.ModelForm):
 | 
			
		||||
        }
 | 
			
		||||
        model = Session
 | 
			
		||||
        fields = [
 | 
			
		||||
            "game",
 | 
			
		||||
            "purchase",
 | 
			
		||||
            "timestamp_start",
 | 
			
		||||
            "timestamp_end",
 | 
			
		||||
            "duration_manual",
 | 
			
		||||
            "emulated",
 | 
			
		||||
            "device",
 | 
			
		||||
            "note",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameChoiceField(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"):
 | 
			
		||||
@ -54,24 +56,26 @@ class PurchaseForm(forms.ModelForm):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        # Automatically update related_purchase <select/>
 | 
			
		||||
        # to only include purchases of the selected game.
 | 
			
		||||
        related_purchase_by_game_url = reverse("related_purchase_by_game")
 | 
			
		||||
        self.fields["games"].widget.attrs.update(
 | 
			
		||||
        # to only include purchases of the selected edition.
 | 
			
		||||
        related_purchase_by_edition_url = reverse("related_purchase_by_edition")
 | 
			
		||||
        self.fields["edition"].widget.attrs.update(
 | 
			
		||||
            {
 | 
			
		||||
                "hx-trigger": "load, click",
 | 
			
		||||
                "hx-get": related_purchase_by_game_url,
 | 
			
		||||
                "hx-get": related_purchase_by_edition_url,
 | 
			
		||||
                "hx-target": "#id_related_purchase",
 | 
			
		||||
                "hx-swap": "outerHTML",
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    games = GameChoiceField(
 | 
			
		||||
        queryset=Game.objects.order_by("sort_name"),
 | 
			
		||||
    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,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@ -84,7 +88,7 @@ class PurchaseForm(forms.ModelForm):
 | 
			
		||||
        }
 | 
			
		||||
        model = Purchase
 | 
			
		||||
        fields = [
 | 
			
		||||
            "games",
 | 
			
		||||
            "edition",
 | 
			
		||||
            "platform",
 | 
			
		||||
            "date_purchased",
 | 
			
		||||
            "date_refunded",
 | 
			
		||||
@ -136,14 +140,24 @@ class GameModelChoiceField(forms.ModelChoiceField):
 | 
			
		||||
        return obj.sort_name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameForm(forms.ModelForm):
 | 
			
		||||
class EditionForm(forms.ModelForm):
 | 
			
		||||
    game = GameModelChoiceField(
 | 
			
		||||
        queryset=Game.objects.order_by("sort_name"),
 | 
			
		||||
        widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
 | 
			
		||||
    )
 | 
			
		||||
    platform = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Platform.objects.order_by("name"), required=False
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Edition
 | 
			
		||||
        fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Game
 | 
			
		||||
        fields = ["name", "sort_name", "platform", "year_released", "wikidata"]
 | 
			
		||||
        fields = ["name", "sort_name", "year_released", "wikidata"]
 | 
			
		||||
        widgets = {"name": autofocus_input_widget}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
from .device import Query as DeviceQuery
 | 
			
		||||
from .edition import Query as EditionQuery
 | 
			
		||||
from .game import Query as GameQuery
 | 
			
		||||
from .platform import Query as PlatformQuery
 | 
			
		||||
from .purchase import Query as PurchaseQuery
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								games/graphql/queries/edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								games/graphql/queries/edition.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
import graphene
 | 
			
		||||
 | 
			
		||||
from games.graphql.types import Edition
 | 
			
		||||
from games.models import Game as EditionModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Query(graphene.ObjectType):
 | 
			
		||||
    editions = graphene.List(Edition)
 | 
			
		||||
 | 
			
		||||
    def resolve_editions(self, info, **kwargs):
 | 
			
		||||
        return EditionModel.objects.all()
 | 
			
		||||
@ -1,6 +1,5 @@
 | 
			
		||||
# Generated by Django 5.1.5 on 2025-01-29 21:26
 | 
			
		||||
# Generated by Django 4.1.4 on 2023-01-02 18:27
 | 
			
		||||
 | 
			
		||||
import datetime
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
@ -9,96 +8,94 @@ class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
    ]
 | 
			
		||||
    dependencies = []
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Device',
 | 
			
		||||
            name="Game",
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.CharField(max_length=255)),
 | 
			
		||||
                ('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
 | 
			
		||||
                ('created_at', models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.BigAutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("name", models.CharField(max_length=255)),
 | 
			
		||||
                ("wikidata", models.CharField(max_length=50)),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Platform',
 | 
			
		||||
            name="Platform",
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.CharField(max_length=255)),
 | 
			
		||||
                ('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
 | 
			
		||||
                ('icon', models.SlugField(blank=True)),
 | 
			
		||||
                ('created_at', models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.BigAutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("name", models.CharField(max_length=255)),
 | 
			
		||||
                ("group", models.CharField(max_length=255)),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='ExchangeRate',
 | 
			
		||||
            name="Purchase",
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('currency_from', models.CharField(max_length=255)),
 | 
			
		||||
                ('currency_to', models.CharField(max_length=255)),
 | 
			
		||||
                ('year', models.PositiveIntegerField()),
 | 
			
		||||
                ('rate', models.FloatField()),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'unique_together': {('currency_from', 'currency_to', 'year')},
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Game',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.CharField(max_length=255)),
 | 
			
		||||
                ('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
 | 
			
		||||
                ('year_released', models.IntegerField(blank=True, default=None, null=True)),
 | 
			
		||||
                ('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
 | 
			
		||||
                ('created_at', models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                ('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'unique_together': {('name', 'platform', 'year_released')},
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Purchase',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('date_purchased', models.DateField()),
 | 
			
		||||
                ('date_refunded', models.DateField(blank=True, null=True)),
 | 
			
		||||
                ('date_finished', models.DateField(blank=True, null=True)),
 | 
			
		||||
                ('date_dropped', models.DateField(blank=True, null=True)),
 | 
			
		||||
                ('infinite', models.BooleanField(default=False)),
 | 
			
		||||
                ('price', models.FloatField(default=0)),
 | 
			
		||||
                ('price_currency', models.CharField(default='USD', max_length=3)),
 | 
			
		||||
                ('converted_price', models.FloatField(null=True)),
 | 
			
		||||
                ('converted_currency', models.CharField(max_length=3, null=True)),
 | 
			
		||||
                ('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
 | 
			
		||||
                ('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
 | 
			
		||||
                ('name', models.CharField(blank=True, default='', max_length=255, null=True)),
 | 
			
		||||
                ('created_at', models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                ('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
 | 
			
		||||
                ('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
 | 
			
		||||
                ('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.BigAutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("date_purchased", models.DateField()),
 | 
			
		||||
                ("date_refunded", models.DateField(blank=True, null=True)),
 | 
			
		||||
                (
 | 
			
		||||
                    "game",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE, to="games.game"
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "platform",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        to="games.platform",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Session',
 | 
			
		||||
            name="Session",
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('timestamp_start', models.DateTimeField()),
 | 
			
		||||
                ('timestamp_end', models.DateTimeField(blank=True, null=True)),
 | 
			
		||||
                ('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
 | 
			
		||||
                ('duration_calculated', models.DurationField(blank=True, null=True)),
 | 
			
		||||
                ('note', models.TextField(blank=True, null=True)),
 | 
			
		||||
                ('emulated', models.BooleanField(default=False)),
 | 
			
		||||
                ('created_at', models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                ('modified_at', models.DateTimeField(auto_now=True)),
 | 
			
		||||
                ('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
 | 
			
		||||
                ('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.BigAutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("timestamp_start", models.DateTimeField()),
 | 
			
		||||
                ("timestamp_end", models.DateTimeField()),
 | 
			
		||||
                ("duration_manual", models.DurationField(blank=True, null=True)),
 | 
			
		||||
                ("duration_calculated", models.DurationField(blank=True, null=True)),
 | 
			
		||||
                ("note", models.TextField(blank=True, null=True)),
 | 
			
		||||
                (
 | 
			
		||||
                    "purchase",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        to="games.purchase",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'get_latest_by': 'timestamp_start',
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								games/migrations/0002_alter_session_duration_manual.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								games/migrations/0002_alter_session_duration_manual.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
# Generated by Django 4.1.4 on 2023-01-02 18:55
 | 
			
		||||
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="duration_manual",
 | 
			
		||||
            field=models.DurationField(
 | 
			
		||||
                blank=True, default=datetime.timedelta(0), null=True
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 4.1.4 on 2023-01-02 23:11
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0002_alter_session_duration_manual"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="duration_manual",
 | 
			
		||||
            field=models.DurationField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="timestamp_end",
 | 
			
		||||
            field=models.DateTimeField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										22
									
								
								games/migrations/0004_alter_session_duration_manual.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								games/migrations/0004_alter_session_duration_manual.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-01-09 14:49
 | 
			
		||||
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0003_alter_session_duration_manual_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="duration_manual",
 | 
			
		||||
            field=models.DurationField(
 | 
			
		||||
                blank=True, default=datetime.timedelta(0), null=True
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										35
									
								
								games/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								games/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-01-09 17:43
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_duration_calculated_none_to_zero(apps, schema_editor):
 | 
			
		||||
    Session = apps.get_model("games", "Session")
 | 
			
		||||
    for session in Session.objects.all():
 | 
			
		||||
        if session.duration_calculated == None:
 | 
			
		||||
            session.duration_calculated = timedelta(0)
 | 
			
		||||
            session.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
 | 
			
		||||
    Session = apps.get_model("games", "Session")
 | 
			
		||||
    for session in Session.objects.all():
 | 
			
		||||
        if session.duration_calculated == timedelta(0):
 | 
			
		||||
            session.duration_calculated = None
 | 
			
		||||
            session.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0004_alter_session_duration_manual"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            set_duration_calculated_none_to_zero,
 | 
			
		||||
            revert_set_duration_calculated_none_to_zero,
 | 
			
		||||
        )
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										35
									
								
								games/migrations/0006_auto_20230109_1904.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								games/migrations/0006_auto_20230109_1904.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-01-09 18:04
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_duration_manual_none_to_zero(apps, schema_editor):
 | 
			
		||||
    Session = apps.get_model("games", "Session")
 | 
			
		||||
    for session in Session.objects.all():
 | 
			
		||||
        if session.duration_manual == None:
 | 
			
		||||
            session.duration_manual = timedelta(0)
 | 
			
		||||
            session.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
 | 
			
		||||
    Session = apps.get_model("games", "Session")
 | 
			
		||||
    for session in Session.objects.all():
 | 
			
		||||
        if session.duration_manual == timedelta(0):
 | 
			
		||||
            session.duration_manual = None
 | 
			
		||||
            session.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0005_auto_20230109_1843"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            set_duration_manual_none_to_zero,
 | 
			
		||||
            revert_set_duration_manual_none_to_zero,
 | 
			
		||||
        )
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,35 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-01-19 18:30
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0006_auto_20230109_1904"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="game",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE, to="games.game"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="platform",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE, to="games.platform"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="purchase",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										41
									
								
								games/migrations/0008_edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								games/migrations/0008_edition.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,41 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-18 16:29
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="Edition",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.BigAutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("name", models.CharField(max_length=255)),
 | 
			
		||||
                (
 | 
			
		||||
                    "game",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE, to="games.game"
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "platform",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE, to="games.platform"
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										34
									
								
								games/migrations/0009_create_editions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								games/migrations/0009_create_editions.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-18 18:51
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_edition_of_game(apps, schema_editor):
 | 
			
		||||
    Game = apps.get_model("games", "Game")
 | 
			
		||||
    Edition = apps.get_model("games", "Edition")
 | 
			
		||||
    Platform = apps.get_model("games", "Platform")
 | 
			
		||||
    first_platform = Platform.objects.first()
 | 
			
		||||
    all_games = Game.objects.all()
 | 
			
		||||
    all_editions = Edition.objects.all()
 | 
			
		||||
    for game in all_games:
 | 
			
		||||
        existing_edition = None
 | 
			
		||||
        try:
 | 
			
		||||
            existing_edition = all_editions.objects.get(game=game.id)
 | 
			
		||||
        except:
 | 
			
		||||
            pass
 | 
			
		||||
        if existing_edition == None:
 | 
			
		||||
            edition = Edition()
 | 
			
		||||
            edition.id = game.id
 | 
			
		||||
            edition.game = game
 | 
			
		||||
            edition.name = game.name
 | 
			
		||||
            edition.platform = first_platform
 | 
			
		||||
            edition.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0008_edition"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [migrations.RunPython(create_edition_of_game)]
 | 
			
		||||
							
								
								
									
										21
									
								
								games/migrations/0010_alter_purchase_game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								games/migrations/0010_alter_purchase_game.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-18 19:06
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0009_create_editions"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="game",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE, to="games.edition"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0011_rename_game_purchase_edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0011_rename_game_purchase_edition.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-18 19:18
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0010_alter_purchase_game"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            old_name="game",
 | 
			
		||||
            new_name="edition",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-18 19:53
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0011_rename_game_purchase_edition"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="price",
 | 
			
		||||
            field=models.IntegerField(default=0),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="price_currency",
 | 
			
		||||
            field=models.CharField(default="USD", max_length=3),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										31
									
								
								games/migrations/0013_purchase_ownership_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								games/migrations/0013_purchase_ownership_type.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-18 19:54
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0012_purchase_price_purchase_price_currency"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="ownership_type",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("ph", "Physical"),
 | 
			
		||||
                    ("di", "Digital"),
 | 
			
		||||
                    ("du", "Digital Upgrade"),
 | 
			
		||||
                    ("re", "Rented"),
 | 
			
		||||
                    ("bo", "Borrowed"),
 | 
			
		||||
                    ("tr", "Trial"),
 | 
			
		||||
                    ("de", "Demo"),
 | 
			
		||||
                    ("pi", "Pirated"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="di",
 | 
			
		||||
                max_length=2,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										52
									
								
								games/migrations/0014_device_session_device.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								games/migrations/0014_device_session_device.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-18 19:59
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0013_purchase_ownership_type"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="Device",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.BigAutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("name", models.CharField(max_length=255)),
 | 
			
		||||
                (
 | 
			
		||||
                    "type",
 | 
			
		||||
                    models.CharField(
 | 
			
		||||
                        choices=[
 | 
			
		||||
                            ("pc", "PC"),
 | 
			
		||||
                            ("co", "Console"),
 | 
			
		||||
                            ("ha", "Handheld"),
 | 
			
		||||
                            ("mo", "Mobile"),
 | 
			
		||||
                            ("sbc", "Single-board computer"),
 | 
			
		||||
                        ],
 | 
			
		||||
                        default="pc",
 | 
			
		||||
                        max_length=3,
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="device",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                to="games.device",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-20 14:55
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0014_device_session_device"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="wikidata",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=50, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="year_released",
 | 
			
		||||
            field=models.IntegerField(default=2023),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,51 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 11:10
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0015_edition_wikidata_edition_year_released"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="platform",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                to="games.platform",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="year_released",
 | 
			
		||||
            field=models.IntegerField(blank=True, default=None, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            name="wikidata",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=50, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="platform",
 | 
			
		||||
            name="group",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=255, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="device",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                to="games.device",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,141 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 18:14
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def rename_duplicates(apps, schema_editor):
 | 
			
		||||
    Edition = apps.get_model("games", "Edition")
 | 
			
		||||
 | 
			
		||||
    duplicates = (
 | 
			
		||||
        Edition.objects.values("name", "platform")
 | 
			
		||||
        .annotate(name_count=models.Count("id"))
 | 
			
		||||
        .filter(name_count__gt=1)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    for duplicate in duplicates:
 | 
			
		||||
        counter = 1
 | 
			
		||||
        duplicate_editions = Edition.objects.filter(
 | 
			
		||||
            name=duplicate["name"], platform_id=duplicate["platform"]
 | 
			
		||||
        ).order_by("id")
 | 
			
		||||
 | 
			
		||||
        for edition in duplicate_editions[1:]:  # Skip the first one
 | 
			
		||||
            edition.name = f"{edition.name} {counter}"
 | 
			
		||||
            edition.save()
 | 
			
		||||
            counter += 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_game_year(apps, schema_editor):
 | 
			
		||||
    Game = apps.get_model("games", "Game")
 | 
			
		||||
    Edition = apps.get_model("games", "Edition")
 | 
			
		||||
 | 
			
		||||
    for game in Game.objects.filter(year__isnull=True):
 | 
			
		||||
        # Try to get the first related edition with a non-null year_released
 | 
			
		||||
        edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
 | 
			
		||||
        if edition:
 | 
			
		||||
            # If an edition is found, update the game's year
 | 
			
		||||
            game.year = edition.year_released
 | 
			
		||||
            game.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    replaces = [
 | 
			
		||||
        ("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
 | 
			
		||||
        ("games", "0017_alter_device_type_alter_purchase_platform"),
 | 
			
		||||
        ("games", "0018_auto_20231106_1825"),
 | 
			
		||||
        ("games", "0019_alter_edition_unique_together"),
 | 
			
		||||
        ("games", "0020_game_year"),
 | 
			
		||||
        ("games", "0021_auto_20231106_1909"),
 | 
			
		||||
        ("games", "0022_rename_year_game_year_released"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0015_edition_wikidata_edition_year_released"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="platform",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                to="games.platform",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="year_released",
 | 
			
		||||
            field=models.IntegerField(blank=True, default=None, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            name="wikidata",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=50, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="platform",
 | 
			
		||||
            name="group",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=255, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="device",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                to="games.device",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="device",
 | 
			
		||||
            name="type",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("pc", "PC"),
 | 
			
		||||
                    ("co", "Console"),
 | 
			
		||||
                    ("ha", "Handheld"),
 | 
			
		||||
                    ("mo", "Mobile"),
 | 
			
		||||
                    ("sbc", "Single-board computer"),
 | 
			
		||||
                    ("un", "Unknown"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="un",
 | 
			
		||||
                max_length=3,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="platform",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                to="games.platform",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            code=rename_duplicates,
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name="edition",
 | 
			
		||||
            unique_together={("name", "platform")},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            name="year",
 | 
			
		||||
            field=models.IntegerField(blank=True, default=None, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            code=update_game_year,
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            old_name="year",
 | 
			
		||||
            new_name="year_released",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,41 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 16:53
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="device",
 | 
			
		||||
            name="type",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("pc", "PC"),
 | 
			
		||||
                    ("co", "Console"),
 | 
			
		||||
                    ("ha", "Handheld"),
 | 
			
		||||
                    ("mo", "Mobile"),
 | 
			
		||||
                    ("sbc", "Single-board computer"),
 | 
			
		||||
                    ("un", "Unknown"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="un",
 | 
			
		||||
                max_length=3,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="platform",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                to="games.platform",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										34
									
								
								games/migrations/0018_auto_20231106_1825.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								games/migrations/0018_auto_20231106_1825.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 17:25
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def rename_duplicates(apps, schema_editor):
 | 
			
		||||
    Edition = apps.get_model("games", "Edition")
 | 
			
		||||
 | 
			
		||||
    duplicates = (
 | 
			
		||||
        Edition.objects.values("name", "platform")
 | 
			
		||||
        .annotate(name_count=models.Count("id"))
 | 
			
		||||
        .filter(name_count__gt=1)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    for duplicate in duplicates:
 | 
			
		||||
        counter = 1
 | 
			
		||||
        duplicate_editions = Edition.objects.filter(
 | 
			
		||||
            name=duplicate["name"], platform_id=duplicate["platform"]
 | 
			
		||||
        ).order_by("id")
 | 
			
		||||
 | 
			
		||||
        for edition in duplicate_editions[1:]:  # Skip the first one
 | 
			
		||||
            edition.name = f"{edition.name} {counter}"
 | 
			
		||||
            edition.save()
 | 
			
		||||
            counter += 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0017_alter_device_type_alter_purchase_platform"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(rename_duplicates),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										17
									
								
								games/migrations/0019_alter_edition_unique_together.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								games/migrations/0019_alter_edition_unique_together.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 17:26
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0018_auto_20231106_1825"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name="edition",
 | 
			
		||||
            unique_together={("name", "platform")},
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0020_game_year.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0020_game_year.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 18:05
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0019_alter_edition_unique_together"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            name="year",
 | 
			
		||||
            field=models.IntegerField(blank=True, default=None, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										24
									
								
								games/migrations/0021_auto_20231106_1909.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								games/migrations/0021_auto_20231106_1909.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_game_year(apps, schema_editor):
 | 
			
		||||
    Game = apps.get_model("games", "Game")
 | 
			
		||||
    Edition = apps.get_model("games", "Edition")
 | 
			
		||||
 | 
			
		||||
    for game in Game.objects.filter(year__isnull=True):
 | 
			
		||||
        # Try to get the first related edition with a non-null year_released
 | 
			
		||||
        edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
 | 
			
		||||
        if edition:
 | 
			
		||||
            # If an edition is found, update the game's year
 | 
			
		||||
            game.year = edition.year_released
 | 
			
		||||
            game.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0020_game_year"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(update_game_year),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0022_rename_year_game_year_released.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0022_rename_year_game_year_released.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 18:12
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0021_auto_20231106_1909"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            old_name="year",
 | 
			
		||||
            new_name="year_released",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										21
									
								
								games/migrations/0023_purchase_date_finished.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								games/migrations/0023_purchase_date_finished.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-06 18:24
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        (
 | 
			
		||||
            "games",
 | 
			
		||||
            "0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="date_finished",
 | 
			
		||||
            field=models.DateField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										39
									
								
								games/migrations/0024_edition_sort_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								games/migrations/0024_edition_sort_name.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-09 09:32
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_sort_name(apps, schema_editor):
 | 
			
		||||
    Edition = apps.get_model(
 | 
			
		||||
        "games", "Edition"
 | 
			
		||||
    )  # Replace 'your_app_name' with the actual name of your app
 | 
			
		||||
 | 
			
		||||
    for edition in Edition.objects.all():
 | 
			
		||||
        name = edition.name
 | 
			
		||||
        # Check for articles at the beginning of the name and move them to the end
 | 
			
		||||
        if name.lower().startswith("the "):
 | 
			
		||||
            sort_name = f"{name[4:]}, The"
 | 
			
		||||
        elif name.lower().startswith("a "):
 | 
			
		||||
            sort_name = f"{name[2:]}, A"
 | 
			
		||||
        elif name.lower().startswith("an "):
 | 
			
		||||
            sort_name = f"{name[3:]}, An"
 | 
			
		||||
        else:
 | 
			
		||||
            sort_name = name
 | 
			
		||||
        # Save the sort_name back to the database
 | 
			
		||||
        edition.sort_name = sort_name
 | 
			
		||||
        edition.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0023_purchase_date_finished"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="sort_name",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=255, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(create_sort_name),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										39
									
								
								games/migrations/0025_game_sort_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								games/migrations/0025_game_sort_name.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-09 09:32
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_sort_name(apps, schema_editor):
 | 
			
		||||
    Game = apps.get_model(
 | 
			
		||||
        "games", "Game"
 | 
			
		||||
    )  # Replace 'your_app_name' with the actual name of your app
 | 
			
		||||
 | 
			
		||||
    for game in Game.objects.all():
 | 
			
		||||
        name = game.name
 | 
			
		||||
        # Check for articles at the beginning of the name and move them to the end
 | 
			
		||||
        if name.lower().startswith("the "):
 | 
			
		||||
            sort_name = f"{name[4:]}, The"
 | 
			
		||||
        elif name.lower().startswith("a "):
 | 
			
		||||
            sort_name = f"{name[2:]}, A"
 | 
			
		||||
        elif name.lower().startswith("an "):
 | 
			
		||||
            sort_name = f"{name[3:]}, An"
 | 
			
		||||
        else:
 | 
			
		||||
            sort_name = name
 | 
			
		||||
        # Save the sort_name back to the database
 | 
			
		||||
        game.sort_name = sort_name
 | 
			
		||||
        game.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0024_edition_sort_name"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            name="sort_name",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=255, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(create_sort_name),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										27
									
								
								games/migrations/0026_purchase_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								games/migrations/0026_purchase_type.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-14 08:35
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0025_game_sort_name"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="type",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("game", "Game"),
 | 
			
		||||
                    ("dlc", "DLC"),
 | 
			
		||||
                    ("season_pass", "Season Pass"),
 | 
			
		||||
                    ("battle_pass", "Battle Pass"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="game",
 | 
			
		||||
                max_length=255,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										25
									
								
								games/migrations/0027_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								games/migrations/0027_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-14 08:41
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0026_purchase_type"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="related_purchase",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.SET_NULL,
 | 
			
		||||
                to="games.purchase",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										26
									
								
								games/migrations/0028_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								games/migrations/0028_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-14 11:05
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
from games.models import Purchase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def null_game_name(apps, schema_editor):
 | 
			
		||||
    Purchase.objects.filter(type=Purchase.GAME).update(name=None)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0027_purchase_related_purchase"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="name",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                blank=True, default="Unknown Name", max_length=255, null=True
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(null_game_name),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										26
									
								
								games/migrations/0029_alter_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								games/migrations/0029_alter_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-14 21:19
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0028_purchase_name"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="related_purchase",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.SET_NULL,
 | 
			
		||||
                related_name="related_purchases",
 | 
			
		||||
                to="games.purchase",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0030_alter_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0030_alter_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-15 12:04
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0029_alter_purchase_related_purchase"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="name",
 | 
			
		||||
            field=models.CharField(blank=True, default="", max_length=255, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,44 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-15 13:51
 | 
			
		||||
 | 
			
		||||
import django.utils.timezone
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0030_alter_purchase_name"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="device",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(default=django.utils.timezone.now),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(default=django.utils.timezone.now),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(default=django.utils.timezone.now),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="platform",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(default=django.utils.timezone.now),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(default=django.utils.timezone.now),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(default=django.utils.timezone.now),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,52 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-11-15 18:02
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name="session",
 | 
			
		||||
            options={"get_latest_by": "timestamp_start"},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="modified_at",
 | 
			
		||||
            field=models.DateTimeField(auto_now=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="device",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(auto_now_add=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(auto_now_add=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="game",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(auto_now_add=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="platform",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(auto_now_add=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(auto_now_add=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="created_at",
 | 
			
		||||
            field=models.DateTimeField(auto_now_add=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										17
									
								
								games/migrations/0033_alter_edition_unique_together.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								games/migrations/0033_alter_edition_unique_together.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
# Generated by Django 4.2.7 on 2023-11-28 13:43
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0032_alter_session_options_session_modified_at_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name="edition",
 | 
			
		||||
            unique_together={("name", "platform", "year_released")},
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 4.2.7 on 2024-01-03 21:27
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0033_alter_edition_unique_together"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="date_dropped",
 | 
			
		||||
            field=models.DateField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="purchase",
 | 
			
		||||
            name="infinite",
 | 
			
		||||
            field=models.BooleanField(default=False),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										25
									
								
								games/migrations/0035_alter_session_device.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								games/migrations/0035_alter_session_device.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
			
		||||
# Generated by Django 5.1 on 2024-08-11 15:50
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0034_purchase_date_dropped_purchase_infinite"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="session",
 | 
			
		||||
            name="device",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.SET_DEFAULT,
 | 
			
		||||
                to="games.device",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										19
									
								
								games/migrations/0036_alter_edition_platform.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								games/migrations/0036_alter_edition_platform.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
# Generated by Django 5.1 on 2024-08-11 16:48
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('games', '0035_alter_session_device'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='edition',
 | 
			
		||||
            name='platform',
 | 
			
		||||
            field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										26
									
								
								games/migrations/0037_platform_icon.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								games/migrations/0037_platform_icon.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
			
		||||
# Generated by Django 5.1.1 on 2024-09-14 07:05
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
from django.utils.text import slugify
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_empty_icons(apps, schema_editor):
 | 
			
		||||
    Platform = apps.get_model("games", "Platform")
 | 
			
		||||
    for platform in Platform.objects.filter(icon=""):
 | 
			
		||||
        platform.icon = slugify(platform.name)
 | 
			
		||||
        platform.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0036_alter_edition_platform"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="platform",
 | 
			
		||||
            name="icon",
 | 
			
		||||
            field=models.SlugField(blank=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(update_empty_icons),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0038_alter_purchase_price.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0038_alter_purchase_price.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 5.1.1 on 2024-10-04 09:23
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('games', '0037_platform_icon'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='purchase',
 | 
			
		||||
            name='price',
 | 
			
		||||
            field=models.FloatField(default=0),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								games/migrations/0039_alter_device_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0039_alter_device_type.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 5.1.2 on 2024-11-09 22:38
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('games', '0038_alter_purchase_price'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='device',
 | 
			
		||||
            name='type',
 | 
			
		||||
            field=models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										33
									
								
								games/migrations/0040_migrate_device_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								games/migrations/0040_migrate_device_types.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
# Generated by Django 5.1.2 on 2024-11-09 22:39
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_device_types(apps, schema_editor):
 | 
			
		||||
    Device = apps.get_model("games", "Device")
 | 
			
		||||
 | 
			
		||||
    # Mapping of short names to long names
 | 
			
		||||
    type_map = {
 | 
			
		||||
        "pc": "PC",
 | 
			
		||||
        "co": "Console",
 | 
			
		||||
        "ha": "Handheld",
 | 
			
		||||
        "mo": "Mobile",
 | 
			
		||||
        "sbc": "Single-board computer",
 | 
			
		||||
        "un": "Unknown",
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # Loop through all devices and update the type field
 | 
			
		||||
    for device in Device.objects.all():
 | 
			
		||||
        if device.type in type_map:
 | 
			
		||||
            device.type = type_map[device.type]
 | 
			
		||||
            device.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0039_alter_device_type"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(update_device_types),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,36 @@
 | 
			
		||||
# Generated by Django 5.1.3 on 2024-11-10 15:14
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('games', '0040_migrate_device_types'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='purchase',
 | 
			
		||||
            name='converted_currency',
 | 
			
		||||
            field=models.CharField(max_length=3, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='purchase',
 | 
			
		||||
            name='converted_price',
 | 
			
		||||
            field=models.FloatField(null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='ExchangeRate',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('currency_from', models.CharField(max_length=255)),
 | 
			
		||||
                ('currency_to', models.CharField(max_length=255)),
 | 
			
		||||
                ('year', models.PositiveIntegerField()),
 | 
			
		||||
                ('rate', models.FloatField()),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'unique_together': {('currency_from', 'currency_to', 'year')},
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -10,17 +10,10 @@ from common.time import format_duration
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
    wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
 | 
			
		||||
    platform = models.ForeignKey(
 | 
			
		||||
        "Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
 | 
			
		||||
    session_average: float | int | timedelta | None
 | 
			
		||||
@ -29,17 +22,6 @@ class Game(models.Model):
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if self.platform is None:
 | 
			
		||||
            self.platform = get_sentinel_platform()
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_sentinel_platform():
 | 
			
		||||
    return Platform.objects.get_or_create(
 | 
			
		||||
        name="Unspecified", icon="unspecified", group="Unspecified"
 | 
			
		||||
    )[0]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Platform(models.Model):
 | 
			
		||||
    name = models.CharField(max_length=255)
 | 
			
		||||
@ -56,6 +38,35 @@ class Platform(models.Model):
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_sentinel_platform():
 | 
			
		||||
    return Platform.objects.get_or_create(
 | 
			
		||||
        name="Unspecified", icon="unspecified", group="Unspecified"
 | 
			
		||||
    )[0]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Edition(models.Model):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        unique_together = [["name", "platform", "year_released"]]
 | 
			
		||||
 | 
			
		||||
    game = models.ForeignKey(Game, on_delete=models.CASCADE)
 | 
			
		||||
    name = models.CharField(max_length=255)
 | 
			
		||||
    sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
 | 
			
		||||
    platform = models.ForeignKey(
 | 
			
		||||
        Platform, on_delete=models.SET_DEFAULT, 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)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.sort_name
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if self.platform is None:
 | 
			
		||||
            self.platform = get_sentinel_platform()
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PurchaseQueryset(models.QuerySet):
 | 
			
		||||
    def refunded(self):
 | 
			
		||||
        return self.filter(date_refunded__isnull=False)
 | 
			
		||||
@ -102,8 +113,7 @@ class Purchase(models.Model):
 | 
			
		||||
 | 
			
		||||
    objects = PurchaseQueryset().as_manager()
 | 
			
		||||
 | 
			
		||||
    games = 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
 | 
			
		||||
    )
 | 
			
		||||
@ -131,34 +141,26 @@ class Purchase(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def standardized_name(self):
 | 
			
		||||
        return self.name if self.name else self.first_game.name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def first_game(self):
 | 
			
		||||
        return self.games.first()
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        additional_info = [
 | 
			
		||||
            self.get_type_display() if self.type != Purchase.GAME else "",
 | 
			
		||||
            (
 | 
			
		||||
                f"{self.first_game.platform} version on {self.platform}"
 | 
			
		||||
                if self.platform != self.first_game.platform
 | 
			
		||||
                f"{self.edition.platform} version on {self.platform}"
 | 
			
		||||
                if self.platform != self.edition.platform
 | 
			
		||||
                else self.platform
 | 
			
		||||
            ),
 | 
			
		||||
            self.first_game.year_released,
 | 
			
		||||
            self.edition.year_released,
 | 
			
		||||
            self.get_ownership_type_display(),
 | 
			
		||||
        ]
 | 
			
		||||
        return (
 | 
			
		||||
            f"{self.first_game} ({', '.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."
 | 
			
		||||
            )
 | 
			
		||||
@ -203,14 +205,7 @@ class Session(models.Model):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        get_latest_by = "timestamp_start"
 | 
			
		||||
 | 
			
		||||
    game = models.ForeignKey(
 | 
			
		||||
        Game,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        null=True,
 | 
			
		||||
        default=None,
 | 
			
		||||
        related_name="sessions",
 | 
			
		||||
    )
 | 
			
		||||
    purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE)
 | 
			
		||||
    timestamp_start = models.DateTimeField()
 | 
			
		||||
    timestamp_end = models.DateTimeField(blank=True, null=True)
 | 
			
		||||
    duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
 | 
			
		||||
@ -223,8 +218,6 @@ class Session(models.Model):
 | 
			
		||||
        default=None,
 | 
			
		||||
    )
 | 
			
		||||
    note = models.TextField(blank=True, null=True)
 | 
			
		||||
    emulated = models.BooleanField(default=False)
 | 
			
		||||
 | 
			
		||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    modified_at = models.DateTimeField(auto_now=True)
 | 
			
		||||
 | 
			
		||||
@ -232,7 +225,7 @@ class Session(models.Model):
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        mark = ", manual" if self.is_manual() else ""
 | 
			
		||||
        return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
 | 
			
		||||
        return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
 | 
			
		||||
 | 
			
		||||
    def finish_now(self):
 | 
			
		||||
        self.timestamp_end = timezone.now()
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ import {
 | 
			
		||||
 | 
			
		||||
let syncData = [
 | 
			
		||||
  {
 | 
			
		||||
    source: "#id_games",
 | 
			
		||||
    source: "#id_edition",
 | 
			
		||||
    source_value: "dataset.platform",
 | 
			
		||||
    target: "#id_platform",
 | 
			
		||||
    target_value: "value",
 | 
			
		||||
@ -36,8 +36,8 @@ getEl("#id_type").onchange = () => {
 | 
			
		||||
 | 
			
		||||
document.body.addEventListener("htmx:beforeRequest", function (event) {
 | 
			
		||||
  // Assuming 'Purchase1' is the element that triggers the HTMX request
 | 
			
		||||
  if (event.target.id === "id_games") {
 | 
			
		||||
    var idEditionValue = document.getElementById("id_games").value;
 | 
			
		||||
  if (event.target.id === "id_edition") {
 | 
			
		||||
    var idEditionValue = document.getElementById("id_edition").value;
 | 
			
		||||
 | 
			
		||||
    // Condition to check - replace this with your actual logic
 | 
			
		||||
    if (idEditionValue != "") {
 | 
			
		||||
 | 
			
		||||
@ -36,7 +36,7 @@ function addToggleButton(targetNode) {
 | 
			
		||||
  targetNode.parentElement.appendChild(manualToggleButton);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const toggleableFields = ["#id_games", "#id_platform"];
 | 
			
		||||
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
 | 
			
		||||
 | 
			
		||||
toggleableFields.map((selector) => {
 | 
			
		||||
  addToggleButton(document.querySelector(selector));
 | 
			
		||||
 | 
			
		||||
@ -33,9 +33,8 @@ def convert_prices():
 | 
			
		||||
 | 
			
		||||
        if not exchange_rate:
 | 
			
		||||
            try:
 | 
			
		||||
                # this API endpoint only accepts lowercase currency string
 | 
			
		||||
                response = requests.get(
 | 
			
		||||
                    f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
 | 
			
		||||
                    f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from}.json"
 | 
			
		||||
                )
 | 
			
		||||
                response.raise_for_status()
 | 
			
		||||
                data = response.json()
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										12
									
								
								games/templates/add_edition.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								games/templates/add_edition.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
			
		||||
<c-layouts.add>
 | 
			
		||||
<c-slot name="additional_row">
 | 
			
		||||
<tr>
 | 
			
		||||
    <td></td>
 | 
			
		||||
    <td>
 | 
			
		||||
        <input type="submit"
 | 
			
		||||
               name="submit_and_redirect"
 | 
			
		||||
               value="Submit & Create Purchase" />
 | 
			
		||||
    </td>
 | 
			
		||||
</tr>
 | 
			
		||||
</c-slot>
 | 
			
		||||
</c-layouts.add>
 | 
			
		||||
@ -5,7 +5,7 @@
 | 
			
		||||
    <td>
 | 
			
		||||
        <input type="submit"
 | 
			
		||||
               name="submit_and_redirect"
 | 
			
		||||
               value="Submit & Create Purchase" />
 | 
			
		||||
               value="Submit & Create Edition" />
 | 
			
		||||
    </td>
 | 
			
		||||
</tr>
 | 
			
		||||
</c-slot>
 | 
			
		||||
 | 
			
		||||
@ -1,6 +0,0 @@
 | 
			
		||||
<c-vars title="Emulated" />
 | 
			
		||||
<c-svg :title=title viewbox="0 0 48 48">
 | 
			
		||||
<c-slot name="path">
 | 
			
		||||
    M 8.5 5 C 6.0324991 5 4 7.0324991 4 9.5 L 4 30.5 C 4 32.967501 6.0324991 35 8.5 35 L 17 35 L 17 40 L 13.5 40 A 1.50015 1.50015 0 1 0 13.5 43 L 18.253906 43 A 1.50015 1.50015 0 0 0 18.740234 43 L 29.253906 43 A 1.50015 1.50015 0 0 0 29.740234 43 L 34.5 43 A 1.50015 1.50015 0 1 0 34.5 40 L 31 40 L 31 35 L 39.5 35 C 41.967501 35 44 32.967501 44 30.5 L 44 9.5 C 44 7.0324991 41.967501 5 39.5 5 L 8.5 5 z M 8.5 8 L 39.5 8 C 40.346499 8 41 8.6535009 41 9.5 L 41 30.5 C 41 31.346499 40.346499 32 39.5 32 L 29.746094 32 A 1.50015 1.50015 0 0 0 29.259766 32 L 18.746094 32 A 1.50015 1.50015 0 0 0 18.259766 32 L 8.5 32 C 7.6535009 32 7 31.346499 7 30.5 L 7 9.5 C 7 8.6535009 7.6535009 8 8.5 8 z M 17.5 12 C 16.136406 12 15 13.136406 15 14.5 L 15 25.5 C 15 26.863594 16.136406 28 17.5 28 L 30.5 28 C 31.863594 28 33 26.863594 33 25.5 L 33 14.5 C 33 13.136406 31.863594 12 30.5 12 L 17.5 12 z M 18 18 L 30 18 L 30 25 L 18 25 L 18 18 z M 20 35 L 28 35 L 28 40 L 20 40 L 20 35 z
 | 
			
		||||
</c-slot>
 | 
			
		||||
</c-svg>
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
 | 
			
		||||
@ -36,8 +36,8 @@
 | 
			
		||||
                            <td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
 | 
			
		||||
                                <span class="inline-block relative">
 | 
			
		||||
                                    <a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
 | 
			
		||||
                                       href="{% url 'view_game' session.game.id %}">
 | 
			
		||||
                                        {{ session.game.name }}
 | 
			
		||||
                                       href="{% url 'view_game' session.purchase.edition.game.id %}">
 | 
			
		||||
                                        {{ session.purchase.edition.name }}
 | 
			
		||||
                                    </a>
 | 
			
		||||
                                </span>
 | 
			
		||||
                            </td>
 | 
			
		||||
 | 
			
		||||
@ -57,6 +57,10 @@
 | 
			
		||||
                                <a href="{% url 'add_game' %}"
 | 
			
		||||
                                   class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
 | 
			
		||||
                            </li>
 | 
			
		||||
                            <li>
 | 
			
		||||
                                <a href="{% url 'add_edition' %}"
 | 
			
		||||
                                   class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Edition</a>
 | 
			
		||||
                            </li>
 | 
			
		||||
                            <li>
 | 
			
		||||
                                <a href="{% url 'add_platform' %}"
 | 
			
		||||
                                   class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
 | 
			
		||||
@ -98,6 +102,10 @@
 | 
			
		||||
                                <a href="{% url 'list_games' %}"
 | 
			
		||||
                                   class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
 | 
			
		||||
                            </li>
 | 
			
		||||
                            <li>
 | 
			
		||||
                                <a href="{% url 'list_editions' %}"
 | 
			
		||||
                                   class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a>
 | 
			
		||||
                            </li>
 | 
			
		||||
                            <li>
 | 
			
		||||
                                <a href="{% url 'list_platforms' %}"
 | 
			
		||||
                                   class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,11 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% partialdef purchase-name %}
 | 
			
		||||
{% if purchase.type != 'game' %}
 | 
			
		||||
    <c-gamelink :game_id=purchase.first_game.id>
 | 
			
		||||
    {{ purchase.name }} ({{ purchase.first_game.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_game.id :name=purchase.first_game.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 %}
 | 
			
		||||
 | 
			
		||||
@ -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 %} <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> <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">
 | 
			
		||||
@ -67,6 +67,10 @@
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <c-h1 :badge="edition_count">Editions</c-h1>
 | 
			
		||||
        <div class="mb-6">
 | 
			
		||||
            <c-simple-table :rows=edition_data.rows :columns=edition_data.columns />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="mb-6">
 | 
			
		||||
            <c-h1 :badge="purchase_count">Purchases</c-h1>
 | 
			
		||||
            <c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns />
 | 
			
		||||
 | 
			
		||||
@ -1,42 +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.games.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:
 | 
			
		||||
        {% if purchase.converted_price %}
 | 
			
		||||
            {{ purchase.converted_price | floatformat }} {{ purchase.converted_currency }}
 | 
			
		||||
        {% else %}
 | 
			
		||||
        None
 | 
			
		||||
        {% endif %}
 | 
			
		||||
         ({{ purchase.price | floatformat }} {{ purchase.price_currency }})
 | 
			
		||||
    </div>
 | 
			
		||||
    <div>
 | 
			
		||||
        <h2 class="text-base">Items:</h2>
 | 
			
		||||
        <ul class="list-disc list-inside">
 | 
			
		||||
        {% for game in purchase.games.all %}
 | 
			
		||||
        <li><c-gamelink :game_id=game.id :name=game.name /></li>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
</c-layouts.base>
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 | 
			
		||||
from games.views import device, game, general, platform, purchase, session
 | 
			
		||||
from games.views import device, edition, game, general, platform, purchase, session
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path("", general.index, name="index"),
 | 
			
		||||
@ -8,6 +8,19 @@ urlpatterns = [
 | 
			
		||||
    path("device/delete/<int:device_id>", device.delete_device, name="delete_device"),
 | 
			
		||||
    path("device/edit/<int:device_id>", device.edit_device, name="edit_device"),
 | 
			
		||||
    path("device/list", device.list_devices, name="list_devices"),
 | 
			
		||||
    path("edition/add", edition.add_edition, name="add_edition"),
 | 
			
		||||
    path(
 | 
			
		||||
        "edition/add/for-game/<int:game_id>",
 | 
			
		||||
        edition.add_edition,
 | 
			
		||||
        name="add_edition_for_game",
 | 
			
		||||
    ),
 | 
			
		||||
    path("edition/<int:edition_id>/edit", edition.edit_edition, name="edit_edition"),
 | 
			
		||||
    path("edition/list", edition.list_editions, name="list_editions"),
 | 
			
		||||
    path(
 | 
			
		||||
        "edition/<int:edition_id>/delete",
 | 
			
		||||
        edition.delete_edition,
 | 
			
		||||
        name="delete_edition",
 | 
			
		||||
    ),
 | 
			
		||||
    path("game/add", game.add_game, name="add_game"),
 | 
			
		||||
    path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
 | 
			
		||||
    path("game/<int:game_id>/view", game.view_game, name="view_game"),
 | 
			
		||||
@ -26,11 +39,6 @@ urlpatterns = [
 | 
			
		||||
    ),
 | 
			
		||||
    path("platform/list", platform.list_platforms, name="list_platforms"),
 | 
			
		||||
    path("purchase/add", purchase.add_purchase, name="add_purchase"),
 | 
			
		||||
    path(
 | 
			
		||||
        "purchase/add/for-game/<int:game_id>",
 | 
			
		||||
        purchase.add_purchase,
 | 
			
		||||
        name="add_purchase_for_game",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "purchase/<int:purchase_id>/edit",
 | 
			
		||||
        purchase.edit_purchase,
 | 
			
		||||
@ -46,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,
 | 
			
		||||
@ -67,15 +70,20 @@ urlpatterns = [
 | 
			
		||||
        name="refund_purchase",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "purchase/related-purchase-by-game",
 | 
			
		||||
        purchase.related_purchase_by_game,
 | 
			
		||||
        name="related_purchase_by_game",
 | 
			
		||||
        "purchase/related-purchase-by-edition",
 | 
			
		||||
        purchase.related_purchase_by_edition,
 | 
			
		||||
        name="related_purchase_by_edition",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "purchase/add/for-edition/<int:edition_id>",
 | 
			
		||||
        purchase.add_purchase,
 | 
			
		||||
        name="add_purchase_for_edition",
 | 
			
		||||
    ),
 | 
			
		||||
    path("session/add", session.add_session, name="add_session"),
 | 
			
		||||
    path(
 | 
			
		||||
        "session/add/for-game/<int:game_id>",
 | 
			
		||||
        "session/add/for-purchase/<int:purchase_id>",
 | 
			
		||||
        session.add_session,
 | 
			
		||||
        name="add_session_for_game",
 | 
			
		||||
        name="add_session_for_purchase",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "session/add/from-game/<int:session_id>",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										154
									
								
								games/views/edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								games/views/edition.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,154 @@
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.decorators import login_required
 | 
			
		||||
from django.core.paginator import Paginator
 | 
			
		||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect, render
 | 
			
		||||
from django.template.loader import render_to_string
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from common.components import (
 | 
			
		||||
    A,
 | 
			
		||||
    Button,
 | 
			
		||||
    Icon,
 | 
			
		||||
    LinkedNameWithPlatformIcon,
 | 
			
		||||
    PopoverTruncated,
 | 
			
		||||
)
 | 
			
		||||
from common.time import dateformat, local_strftime
 | 
			
		||||
from games.forms import EditionForm
 | 
			
		||||
from games.models import Edition, Game
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def list_editions(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    context: dict[Any, Any] = {}
 | 
			
		||||
    page_number = request.GET.get("page", 1)
 | 
			
		||||
    limit = request.GET.get("limit", 10)
 | 
			
		||||
    editions = Edition.objects.order_by("-created_at")
 | 
			
		||||
    page_obj = None
 | 
			
		||||
    if int(limit) != 0:
 | 
			
		||||
        paginator = Paginator(editions, limit)
 | 
			
		||||
        page_obj = paginator.get_page(page_number)
 | 
			
		||||
        editions = page_obj.object_list
 | 
			
		||||
 | 
			
		||||
    context = {
 | 
			
		||||
        "title": "Manage editions",
 | 
			
		||||
        "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 edition"), url="add_edition"),
 | 
			
		||||
            "columns": [
 | 
			
		||||
                "Game",
 | 
			
		||||
                "Name",
 | 
			
		||||
                "Sort Name",
 | 
			
		||||
                "Year",
 | 
			
		||||
                "Wikidata",
 | 
			
		||||
                "Created",
 | 
			
		||||
                "Actions",
 | 
			
		||||
            ],
 | 
			
		||||
            "rows": [
 | 
			
		||||
                [
 | 
			
		||||
                    LinkedNameWithPlatformIcon(
 | 
			
		||||
                        name=edition.name,
 | 
			
		||||
                        game_id=edition.game.id,
 | 
			
		||||
                        platform=edition.platform,
 | 
			
		||||
                    ),
 | 
			
		||||
                    PopoverTruncated(
 | 
			
		||||
                        edition.name
 | 
			
		||||
                        if edition.game.name != edition.name
 | 
			
		||||
                        else "(identical)"
 | 
			
		||||
                    ),
 | 
			
		||||
                    PopoverTruncated(
 | 
			
		||||
                        edition.sort_name
 | 
			
		||||
                        if edition.sort_name is not None
 | 
			
		||||
                        and edition.game.name != edition.sort_name
 | 
			
		||||
                        else "(identical)"
 | 
			
		||||
                    ),
 | 
			
		||||
                    edition.year_released,
 | 
			
		||||
                    edition.wikidata,
 | 
			
		||||
                    local_strftime(edition.created_at, dateformat),
 | 
			
		||||
                    render_to_string(
 | 
			
		||||
                        "cotton/button_group.html",
 | 
			
		||||
                        {
 | 
			
		||||
                            "buttons": [
 | 
			
		||||
                                {
 | 
			
		||||
                                    "href": reverse("edit_edition", args=[edition.pk]),
 | 
			
		||||
                                    "slot": Icon("edit"),
 | 
			
		||||
                                    "color": "gray",
 | 
			
		||||
                                },
 | 
			
		||||
                                {
 | 
			
		||||
                                    "href": reverse(
 | 
			
		||||
                                        "delete_edition", args=[edition.pk]
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    "slot": Icon("delete"),
 | 
			
		||||
                                    "color": "red",
 | 
			
		||||
                                },
 | 
			
		||||
                            ]
 | 
			
		||||
                        },
 | 
			
		||||
                    ),
 | 
			
		||||
                ]
 | 
			
		||||
                for edition in editions
 | 
			
		||||
            ],
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
    return render(request, "list_purchases.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def edit_edition(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
 | 
			
		||||
    edition = get_object_or_404(Edition, id=edition_id)
 | 
			
		||||
    form = EditionForm(request.POST or None, instance=edition)
 | 
			
		||||
    if form.is_valid():
 | 
			
		||||
        form.save()
 | 
			
		||||
        return redirect("list_editions")
 | 
			
		||||
 | 
			
		||||
    context: dict[str, Any] = {"form": form, "title": "Edit edition"}
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def delete_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
 | 
			
		||||
    edition = get_object_or_404(Edition, id=edition_id)
 | 
			
		||||
    edition.delete()
 | 
			
		||||
    return redirect("list_editions")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
 | 
			
		||||
    context: dict[str, Any] = {}
 | 
			
		||||
    if request.method == "POST":
 | 
			
		||||
        form = EditionForm(request.POST or None)
 | 
			
		||||
        if form.is_valid():
 | 
			
		||||
            edition = form.save()
 | 
			
		||||
            if "submit_and_redirect" in request.POST:
 | 
			
		||||
                return HttpResponseRedirect(
 | 
			
		||||
                    reverse(
 | 
			
		||||
                        "add_purchase_for_edition", kwargs={"edition_id": edition.id}
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
                return redirect("index")
 | 
			
		||||
    else:
 | 
			
		||||
        if game_id:
 | 
			
		||||
            game = get_object_or_404(Game, id=game_id)
 | 
			
		||||
            form = EditionForm(
 | 
			
		||||
                initial={
 | 
			
		||||
                    "game": game,
 | 
			
		||||
                    "name": game.name,
 | 
			
		||||
                    "sort_name": game.sort_name,
 | 
			
		||||
                    "year_released": game.year_released,
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            form = EditionForm()
 | 
			
		||||
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    context["title"] = "Add New Edition"
 | 
			
		||||
    context["script_name"] = "add_edition.js"
 | 
			
		||||
    return render(request, "add_edition.html", context)
 | 
			
		||||
@ -13,11 +13,9 @@ from common.components import (
 | 
			
		||||
    Button,
 | 
			
		||||
    Div,
 | 
			
		||||
    Icon,
 | 
			
		||||
    LinkedPurchase,
 | 
			
		||||
    NameWithIcon,
 | 
			
		||||
    NameWithPlatformIcon,
 | 
			
		||||
    Popover,
 | 
			
		||||
    PopoverTruncated,
 | 
			
		||||
    PurchasePrice,
 | 
			
		||||
)
 | 
			
		||||
from common.time import (
 | 
			
		||||
    dateformat,
 | 
			
		||||
@ -27,9 +25,9 @@ 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 Game, Purchase
 | 
			
		||||
from games.models import Edition, Game, Purchase, Session
 | 
			
		||||
from games.views.general import use_custom_redirect
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -67,7 +65,18 @@ def list_games(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
            ],
 | 
			
		||||
            "rows": [
 | 
			
		||||
                [
 | 
			
		||||
                    NameWithIcon(game_id=game.pk),
 | 
			
		||||
                    A(
 | 
			
		||||
                        [
 | 
			
		||||
                            (
 | 
			
		||||
                                "href",
 | 
			
		||||
                                reverse(
 | 
			
		||||
                                    "view_game",
 | 
			
		||||
                                    args=[game.pk],
 | 
			
		||||
                                ),
 | 
			
		||||
                            )
 | 
			
		||||
                        ],
 | 
			
		||||
                        PopoverTruncated(game.name),
 | 
			
		||||
                    ),
 | 
			
		||||
                    PopoverTruncated(
 | 
			
		||||
                        game.sort_name
 | 
			
		||||
                        if game.sort_name is not None and game.name != game.sort_name
 | 
			
		||||
@ -109,7 +118,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        game = form.save()
 | 
			
		||||
        if "submit_and_redirect" in request.POST:
 | 
			
		||||
            return HttpResponseRedirect(
 | 
			
		||||
                reverse("add_purchase_for_game", kwargs={"game_id": game.id})
 | 
			
		||||
                reverse("add_edition_for_game", kwargs={"game_id": game.id})
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            return redirect("list_games")
 | 
			
		||||
@ -152,18 +161,27 @@ 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
 | 
			
		||||
        ),
 | 
			
		||||
        to_attr="game_purchases",
 | 
			
		||||
    )
 | 
			
		||||
    editions = (
 | 
			
		||||
        Edition.objects.filter(game=game)
 | 
			
		||||
        .prefetch_related(game_purchases_prefetch)
 | 
			
		||||
        .order_by("year_released")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    purchases = game.purchases.order_by("date_purchased")
 | 
			
		||||
    purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased")
 | 
			
		||||
 | 
			
		||||
    sessions = game.sessions
 | 
			
		||||
    sessions = Session.objects.prefetch_related("device").filter(
 | 
			
		||||
        purchase__edition__game=game
 | 
			
		||||
    )
 | 
			
		||||
    session_count = sessions.count()
 | 
			
		||||
    session_count_without_manual = game.sessions.without_manual().count()
 | 
			
		||||
    session_count_without_manual = (
 | 
			
		||||
        Session.objects.without_manual().filter(purchase__edition__game=game).count()
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if sessions:
 | 
			
		||||
        playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
 | 
			
		||||
@ -184,14 +202,52 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    edition_data: dict[str, Any] = {
 | 
			
		||||
        "columns": [
 | 
			
		||||
            "Name",
 | 
			
		||||
            "Year Released",
 | 
			
		||||
            "Actions",
 | 
			
		||||
        ],
 | 
			
		||||
        "rows": [
 | 
			
		||||
            [
 | 
			
		||||
                NameWithPlatformIcon(
 | 
			
		||||
                    name=edition.name,
 | 
			
		||||
                    platform=edition.platform,
 | 
			
		||||
                ),
 | 
			
		||||
                edition.year_released,
 | 
			
		||||
                render_to_string(
 | 
			
		||||
                    "cotton/button_group.html",
 | 
			
		||||
                    {
 | 
			
		||||
                        "buttons": [
 | 
			
		||||
                            {
 | 
			
		||||
                                "href": reverse("edit_edition", args=[edition.pk]),
 | 
			
		||||
                                "slot": Icon("edit"),
 | 
			
		||||
                                "color": "gray",
 | 
			
		||||
                            },
 | 
			
		||||
                            {
 | 
			
		||||
                                "href": reverse("delete_edition", args=[edition.pk]),
 | 
			
		||||
                                "slot": Icon("delete"),
 | 
			
		||||
                                "color": "red",
 | 
			
		||||
                            },
 | 
			
		||||
                        ]
 | 
			
		||||
                    },
 | 
			
		||||
                ),
 | 
			
		||||
            ]
 | 
			
		||||
            for edition in editions
 | 
			
		||||
        ],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    purchase_data: dict[str, Any] = {
 | 
			
		||||
        "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",
 | 
			
		||||
                    {
 | 
			
		||||
@ -214,8 +270,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        ],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sessions_all = game.sessions.order_by("-timestamp_start")
 | 
			
		||||
 | 
			
		||||
    sessions_all = Session.objects.filter(purchase__edition__game=game).order_by(
 | 
			
		||||
        "-timestamp_start"
 | 
			
		||||
    )
 | 
			
		||||
    last_session = None
 | 
			
		||||
    if sessions_all.exists():
 | 
			
		||||
        last_session = sessions_all.latest()
 | 
			
		||||
@ -242,7 +299,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
                        args=[last_session.pk],
 | 
			
		||||
                    ),
 | 
			
		||||
                    children=Popover(
 | 
			
		||||
                        popover_content=last_session.game.name,
 | 
			
		||||
                        popover_content=last_session.purchase.edition.name,
 | 
			
		||||
                        children=[
 | 
			
		||||
                            Button(
 | 
			
		||||
                                icon=True,
 | 
			
		||||
@ -250,7 +307,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
                                size="xs",
 | 
			
		||||
                                children=[
 | 
			
		||||
                                    Icon("play"),
 | 
			
		||||
                                    truncate(f"{last_session.game.name}"),
 | 
			
		||||
                                    truncate(f"{last_session.purchase.edition.name}"),
 | 
			
		||||
                                ],
 | 
			
		||||
                            )
 | 
			
		||||
                        ],
 | 
			
		||||
@ -260,13 +317,16 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
                else "",
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        "columns": ["Game", "Date", "Duration", "Actions"],
 | 
			
		||||
        "columns": ["Edition", "Date", "Duration", "Actions"],
 | 
			
		||||
        "rows": [
 | 
			
		||||
            [
 | 
			
		||||
                NameWithIcon(
 | 
			
		||||
                    session_id=session.pk,
 | 
			
		||||
                NameWithPlatformIcon(
 | 
			
		||||
                    name=session.purchase.name
 | 
			
		||||
                    if session.purchase.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 ''}",
 | 
			
		||||
                f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}",
 | 
			
		||||
                (
 | 
			
		||||
                    format_duration(session.duration_calculated, durationformat)
 | 
			
		||||
                    if session.duration_calculated
 | 
			
		||||
@ -310,9 +370,11 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    context: dict[str, Any] = {
 | 
			
		||||
        "edition_count": editions.count(),
 | 
			
		||||
        "editions": editions,
 | 
			
		||||
        "game": game,
 | 
			
		||||
        "playrange": playrange,
 | 
			
		||||
        "purchase_count": game.purchases.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)
 | 
			
		||||
@ -323,6 +385,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
 | 
			
		||||
        "sessions": sessions,
 | 
			
		||||
        "title": f"Game Overview - {game.name}",
 | 
			
		||||
        "hours_sum": total_hours,
 | 
			
		||||
        "edition_data": edition_data,
 | 
			
		||||
        "purchase_data": purchase_data,
 | 
			
		||||
        "session_data": session_data,
 | 
			
		||||
        "session_page_obj": session_page_obj,
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
@ -11,12 +11,13 @@ from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from common.time import available_stats_year_range, dateformat, format_duration
 | 
			
		||||
from common.utils import safe_division
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session
 | 
			
		||||
from games.models import Edition, Game, Platform, Purchase, Session
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
 | 
			
		||||
    return {
 | 
			
		||||
        "game_available": Game.objects.exists(),
 | 
			
		||||
        "edition_available": Edition.objects.exists(),
 | 
			
		||||
        "platform_available": Platform.objects.exists(),
 | 
			
		||||
        "purchase_available": Purchase.objects.exists(),
 | 
			
		||||
        "session_count": Session.objects.exists(),
 | 
			
		||||
@ -48,7 +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("game"))
 | 
			
		||||
    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"),
 | 
			
		||||
@ -56,9 +57,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
    longest_session = this_year_sessions_with_durations.order_by("-duration").first()
 | 
			
		||||
    this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
 | 
			
		||||
    this_year_games = Game.objects.filter(
 | 
			
		||||
        edition__purchase__session__in=this_year_sessions
 | 
			
		||||
    ).distinct()
 | 
			
		||||
    this_year_games_with_session_counts = this_year_games.annotate(
 | 
			
		||||
        session_count=Count("sessions"),
 | 
			
		||||
        session_count=Count("edition__purchase__session"),
 | 
			
		||||
    )
 | 
			
		||||
    game_highest_session_count = this_year_games_with_session_counts.order_by(
 | 
			
		||||
        "-session_count"
 | 
			
		||||
@ -71,11 +74,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        .aggregate(dates=Count("date"))
 | 
			
		||||
    )
 | 
			
		||||
    this_year_played_purchases = Purchase.objects.filter(
 | 
			
		||||
        games__sessions__in=this_year_sessions
 | 
			
		||||
        session__in=this_year_sessions
 | 
			
		||||
    ).distinct()
 | 
			
		||||
 | 
			
		||||
    this_year_purchases = Purchase.objects.all()
 | 
			
		||||
    this_year_purchases_with_currency = this_year_purchases.select_related("games")
 | 
			
		||||
    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
 | 
			
		||||
    )
 | 
			
		||||
@ -124,10 +127,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    total_spent = this_year_spendings["total_spent"] or 0
 | 
			
		||||
 | 
			
		||||
    games_with_playtime = (
 | 
			
		||||
        Game.objects.filter(sessions__in=this_year_sessions)
 | 
			
		||||
        Game.objects.filter(edition__purchase__session__in=this_year_sessions)
 | 
			
		||||
        .annotate(
 | 
			
		||||
            total_playtime=Sum(
 | 
			
		||||
                F("sessions__duration_calculated") + F("sessions__duration_manual")
 | 
			
		||||
                F("edition__purchase__session__duration_calculated")
 | 
			
		||||
                + F("edition__purchase__session__duration_manual")
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        .values("id", "name", "total_playtime")
 | 
			
		||||
@ -142,8 +146,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        month["playtime"] = format_duration(month["playtime"], "%2.0H")
 | 
			
		||||
 | 
			
		||||
    highest_session_average_game = (
 | 
			
		||||
        Game.objects.filter(sessions__in=this_year_sessions)
 | 
			
		||||
        .annotate(session_average=Avg("sessions__duration_calculated"))
 | 
			
		||||
        Game.objects.filter(edition__purchase__session__in=this_year_sessions)
 | 
			
		||||
        .annotate(
 | 
			
		||||
            session_average=Avg("edition__purchase__session__duration_calculated")
 | 
			
		||||
        )
 | 
			
		||||
        .order_by("-session_average")
 | 
			
		||||
        .first()
 | 
			
		||||
    )
 | 
			
		||||
@ -152,9 +158,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
 | 
			
		||||
 | 
			
		||||
    total_playtime_per_platform = (
 | 
			
		||||
        this_year_sessions.values("game__platform__name")
 | 
			
		||||
        this_year_sessions.values("purchase__platform__name")
 | 
			
		||||
        .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
 | 
			
		||||
        .annotate(platform_name=F("game__platform__name"))
 | 
			
		||||
        .annotate(platform_name=F("purchase__platform__name"))
 | 
			
		||||
        .values("platform_name", "total_playtime")
 | 
			
		||||
        .order_by("-total_playtime")
 | 
			
		||||
    )
 | 
			
		||||
@ -169,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.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.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()
 | 
			
		||||
@ -220,7 +226,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
            if longest_session
 | 
			
		||||
            else 0
 | 
			
		||||
        ),
 | 
			
		||||
        "longest_session_game": (longest_session.game if longest_session else None),
 | 
			
		||||
        "longest_session_game": (
 | 
			
		||||
            longest_session.purchase.edition.game if longest_session else None
 | 
			
		||||
        ),
 | 
			
		||||
        "highest_session_count": (
 | 
			
		||||
            game_highest_session_count.session_count
 | 
			
		||||
            if game_highest_session_count
 | 
			
		||||
@ -258,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("game")
 | 
			
		||||
    ).select_related("purchase__edition")
 | 
			
		||||
    this_year_sessions_with_durations = this_year_sessions.annotate(
 | 
			
		||||
        duration=ExpressionWrapper(
 | 
			
		||||
            F("timestamp_end") - F("timestamp_start"),
 | 
			
		||||
@ -266,11 +274,13 @@ 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(sessions__in=this_year_sessions).distinct()
 | 
			
		||||
    this_year_games = Game.objects.filter(
 | 
			
		||||
        edition__purchase__session__in=this_year_sessions
 | 
			
		||||
    ).distinct()
 | 
			
		||||
    this_year_games_with_session_counts = this_year_games.annotate(
 | 
			
		||||
        session_count=Count(
 | 
			
		||||
            "sessions",
 | 
			
		||||
            filter=Q(sessions__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(
 | 
			
		||||
@ -284,11 +294,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
        .aggregate(dates=Count("date"))
 | 
			
		||||
    )
 | 
			
		||||
    this_year_played_purchases = Purchase.objects.filter(
 | 
			
		||||
        games__sessions__in=this_year_sessions
 | 
			
		||||
        session__in=this_year_sessions
 | 
			
		||||
    ).distinct()
 | 
			
		||||
 | 
			
		||||
    this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
 | 
			
		||||
    this_year_purchases_with_currency = this_year_purchases.prefetch_related("games")
 | 
			
		||||
    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)
 | 
			
		||||
@ -325,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(games__year_released=year).order_by(
 | 
			
		||||
        purchases_finished_this_year.filter(edition__year_released=year).order_by(
 | 
			
		||||
            "date_finished"
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
@ -339,10 +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(sessions__in=this_year_sessions)
 | 
			
		||||
        Game.objects.filter(edition__purchase__session__in=this_year_sessions)
 | 
			
		||||
        .annotate(
 | 
			
		||||
            total_playtime=Sum(
 | 
			
		||||
                F("sessions__duration_calculated") + F("sessions__duration_manual")
 | 
			
		||||
                F("edition__purchase__session__duration_calculated")
 | 
			
		||||
                + F("edition__purchase__session__duration_manual")
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        .values("id", "name", "total_playtime")
 | 
			
		||||
@ -357,8 +368,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
        month["playtime"] = format_duration(month["playtime"], "%2.0H")
 | 
			
		||||
 | 
			
		||||
    highest_session_average_game = (
 | 
			
		||||
        Game.objects.filter(sessions__in=this_year_sessions)
 | 
			
		||||
        .annotate(session_average=Avg("sessions__duration_calculated"))
 | 
			
		||||
        Game.objects.filter(edition__purchase__session__in=this_year_sessions)
 | 
			
		||||
        .annotate(
 | 
			
		||||
            session_average=Avg("edition__purchase__session__duration_calculated")
 | 
			
		||||
        )
 | 
			
		||||
        .order_by("-session_average")
 | 
			
		||||
        .first()
 | 
			
		||||
    )
 | 
			
		||||
@ -367,9 +380,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
        game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
 | 
			
		||||
 | 
			
		||||
    total_playtime_per_platform = (
 | 
			
		||||
        this_year_sessions.values("game__platform__name")
 | 
			
		||||
        this_year_sessions.values("purchase__platform__name")
 | 
			
		||||
        .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
 | 
			
		||||
        .annotate(platform_name=F("game__platform__name"))
 | 
			
		||||
        .annotate(platform_name=F("purchase__platform__name"))
 | 
			
		||||
        .values("platform_name", "total_playtime")
 | 
			
		||||
        .order_by("-total_playtime")
 | 
			
		||||
    )
 | 
			
		||||
@ -388,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.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.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()
 | 
			
		||||
@ -408,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(
 | 
			
		||||
            games__year_released=year
 | 
			
		||||
            edition__year_released=year
 | 
			
		||||
        ).count(),
 | 
			
		||||
        "top_10_games_by_playtime": top_10_games_by_playtime,
 | 
			
		||||
        "year": year,
 | 
			
		||||
@ -419,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(
 | 
			
		||||
            "games"
 | 
			
		||||
        "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(
 | 
			
		||||
            "games"
 | 
			
		||||
        "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(
 | 
			
		||||
            "games"
 | 
			
		||||
        "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"],
 | 
			
		||||
@ -457,7 +470,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
 | 
			
		||||
            if longest_session
 | 
			
		||||
            else 0
 | 
			
		||||
        ),
 | 
			
		||||
        "longest_session_game": (longest_session.game if longest_session else None),
 | 
			
		||||
        "longest_session_game": (
 | 
			
		||||
            longest_session.purchase.edition.game if longest_session else None
 | 
			
		||||
        ),
 | 
			
		||||
        "highest_session_count": (
 | 
			
		||||
            game_highest_session_count.session_count
 | 
			
		||||
            if game_highest_session_count
 | 
			
		||||
 | 
			
		||||
@ -13,10 +13,19 @@ 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 Game, Purchase
 | 
			
		||||
from games.models import Edition, Purchase
 | 
			
		||||
from games.views.general import use_custom_redirect
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -26,119 +35,146 @@ 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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
 | 
			
		||||
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
 | 
			
		||||
    context: dict[str, Any] = {}
 | 
			
		||||
    initial = {"date_purchased": timezone.now()}
 | 
			
		||||
 | 
			
		||||
@ -149,20 +185,19 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
 | 
			
		||||
            if "submit_and_redirect" in request.POST:
 | 
			
		||||
                return HttpResponseRedirect(
 | 
			
		||||
                    reverse(
 | 
			
		||||
                        "add_session_for_game",
 | 
			
		||||
                        kwargs={"game_id": purchase.first_game.id},
 | 
			
		||||
                        "add_session_for_purchase", kwargs={"purchase_id": purchase.id}
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
                return redirect("list_purchases")
 | 
			
		||||
    else:
 | 
			
		||||
        if game_id:
 | 
			
		||||
            game = Game.objects.get(id=game_id)
 | 
			
		||||
        if edition_id:
 | 
			
		||||
            edition = Edition.objects.get(id=edition_id)
 | 
			
		||||
            form = PurchaseForm(
 | 
			
		||||
                initial={
 | 
			
		||||
                    **initial,
 | 
			
		||||
                    "games": [game],
 | 
			
		||||
                    "platform": game.platform,
 | 
			
		||||
                    "edition": edition,
 | 
			
		||||
                    "platform": edition.platform,
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
@ -170,7 +205,7 @@ def add_purchase(request: HttpRequest, game_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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -186,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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -197,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)
 | 
			
		||||
@ -227,14 +256,12 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
 | 
			
		||||
    return redirect("list_purchases")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    games = request.GET.getlist("games")
 | 
			
		||||
    if not games:
 | 
			
		||||
        return HttpResponseBadRequest("Invalid game_id")
 | 
			
		||||
    if isinstance(games, int) or isinstance(games, str):
 | 
			
		||||
        games = [games]
 | 
			
		||||
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    edition_id = request.GET.get("edition")
 | 
			
		||||
    if not edition_id:
 | 
			
		||||
        return HttpResponseBadRequest("Invalid edition_id")
 | 
			
		||||
    form = PurchaseForm()
 | 
			
		||||
    form.fields["related_purchase"].queryset = Purchase.objects.filter(
 | 
			
		||||
        games__in=games, type=Purchase.GAME
 | 
			
		||||
    ).order_by("games__sort_name")
 | 
			
		||||
        edition_id=edition_id, type=Purchase.GAME
 | 
			
		||||
    ).order_by("edition__sort_name")
 | 
			
		||||
    return render(request, "partials/related_purchase_field.html", {"form": form})
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,7 @@ from common.components import (
 | 
			
		||||
    Div,
 | 
			
		||||
    Form,
 | 
			
		||||
    Icon,
 | 
			
		||||
    NameWithIcon,
 | 
			
		||||
    LinkedNameWithPlatformIcon,
 | 
			
		||||
    Popover,
 | 
			
		||||
)
 | 
			
		||||
from common.time import (
 | 
			
		||||
@ -28,7 +28,7 @@ from common.time import (
 | 
			
		||||
)
 | 
			
		||||
from common.utils import truncate
 | 
			
		||||
from games.forms import SessionForm
 | 
			
		||||
from games.models import Game, Session
 | 
			
		||||
from games.models import Purchase, Session
 | 
			
		||||
from games.views.general import use_custom_redirect
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -37,20 +37,17 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
 | 
			
		||||
    context: dict[Any, Any] = {}
 | 
			
		||||
    page_number = request.GET.get("page", 1)
 | 
			
		||||
    limit = request.GET.get("limit", 10)
 | 
			
		||||
    sessions = Session.objects.order_by("-timestamp_start", "created_at")
 | 
			
		||||
    sessions = Session.objects.order_by("-timestamp_start")
 | 
			
		||||
    search_string = request.GET.get("search_string", search_string)
 | 
			
		||||
    if search_string != "":
 | 
			
		||||
        sessions = sessions.filter(
 | 
			
		||||
            Q(game__name__icontains=search_string)
 | 
			
		||||
            | Q(game__name__icontains=search_string)
 | 
			
		||||
            | Q(game__platform__name__icontains=search_string)
 | 
			
		||||
            Q(purchase__edition__name__icontains=search_string)
 | 
			
		||||
            | Q(purchase__edition__game__name__icontains=search_string)
 | 
			
		||||
            | Q(purchase__platform__name__icontains=search_string)
 | 
			
		||||
            | 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.game.name,
 | 
			
		||||
                                    popover_content=last_session.purchase.edition.name,
 | 
			
		||||
                                    children=[
 | 
			
		||||
                                        Button(
 | 
			
		||||
                                            icon=True,
 | 
			
		||||
@ -105,14 +102,14 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
 | 
			
		||||
                                            size="xs",
 | 
			
		||||
                                            children=[
 | 
			
		||||
                                                Icon("play"),
 | 
			
		||||
                                                truncate(f"{last_session.game.name}"),
 | 
			
		||||
                                                truncate(
 | 
			
		||||
                                                    f"{last_session.purchase.edition.name}"
 | 
			
		||||
                                                ),
 | 
			
		||||
                                            ],
 | 
			
		||||
                                        )
 | 
			
		||||
                                    ],
 | 
			
		||||
                                ),
 | 
			
		||||
                            )
 | 
			
		||||
                            if last_session
 | 
			
		||||
                            else "",
 | 
			
		||||
                            ),
 | 
			
		||||
                        ]
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
@ -128,8 +125,12 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
 | 
			
		||||
            ],
 | 
			
		||||
            "rows": [
 | 
			
		||||
                [
 | 
			
		||||
                    NameWithIcon(session_id=session.pk),
 | 
			
		||||
                    f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
 | 
			
		||||
                    LinkedNameWithPlatformIcon(
 | 
			
		||||
                        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 ""}",
 | 
			
		||||
                    (
 | 
			
		||||
                        format_duration(session.duration_calculated, durationformat)
 | 
			
		||||
                        if session.duration_calculated
 | 
			
		||||
@ -189,13 +190,13 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
 | 
			
		||||
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
 | 
			
		||||
    context = {}
 | 
			
		||||
    initial: dict[str, Any] = {"timestamp_start": timezone.now()}
 | 
			
		||||
 | 
			
		||||
    last = Session.objects.last()
 | 
			
		||||
    if last != None:
 | 
			
		||||
        initial["game"] = last.game
 | 
			
		||||
        initial["purchase"] = last.purchase
 | 
			
		||||
 | 
			
		||||
    if request.method == "POST":
 | 
			
		||||
        form = SessionForm(request.POST or None, initial=initial)
 | 
			
		||||
@ -203,12 +204,12 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
 | 
			
		||||
            form.save()
 | 
			
		||||
            return redirect("list_sessions")
 | 
			
		||||
    else:
 | 
			
		||||
        if game_id:
 | 
			
		||||
            game = Game.objects.get(id=game_id)
 | 
			
		||||
        if purchase_id:
 | 
			
		||||
            purchase = Purchase.objects.get(id=purchase_id)
 | 
			
		||||
            form = SessionForm(
 | 
			
		||||
                initial={
 | 
			
		||||
                    **initial,
 | 
			
		||||
                    "game": game,
 | 
			
		||||
                    "purchase": purchase,
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										76
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										76
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							@ -1,4 +1,4 @@
 | 
			
		||||
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand.
 | 
			
		||||
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "asgiref"
 | 
			
		||||
@ -6,7 +6,6 @@ version = "3.8.1"
 | 
			
		||||
description = "ASGI specs, helper code, and adapters"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"},
 | 
			
		||||
    {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"},
 | 
			
		||||
@ -21,7 +20,6 @@ version = "2024.8.30"
 | 
			
		||||
description = "Python package for providing Mozilla's CA Bundle."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.6"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
 | 
			
		||||
    {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
 | 
			
		||||
@ -33,7 +31,6 @@ version = "3.4.0"
 | 
			
		||||
description = "Validate configuration and produce human readable error messages."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
 | 
			
		||||
    {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
 | 
			
		||||
@ -45,7 +42,6 @@ version = "3.4.0"
 | 
			
		||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7.0"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
 | 
			
		||||
    {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
 | 
			
		||||
@ -160,7 +156,6 @@ version = "8.1.7"
 | 
			
		||||
description = "Composable command line interface toolkit"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
 | 
			
		||||
    {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
 | 
			
		||||
@ -175,12 +170,10 @@ version = "0.4.6"
 | 
			
		||||
description = "Cross-platform colored terminal text."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
 | 
			
		||||
    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 | 
			
		||||
]
 | 
			
		||||
markers = {main = "platform_system == \"Windows\""}
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "croniter"
 | 
			
		||||
@ -188,7 +181,6 @@ version = "5.0.1"
 | 
			
		||||
description = "croniter provides iteration for datetime object with cron like format"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"},
 | 
			
		||||
    {file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"},
 | 
			
		||||
@ -204,7 +196,6 @@ version = "1.15.1"
 | 
			
		||||
description = "CSS unobfuscator and beautifier."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"},
 | 
			
		||||
]
 | 
			
		||||
@ -220,7 +211,6 @@ version = "0.3.9"
 | 
			
		||||
description = "Distribution utilities"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
 | 
			
		||||
    {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
 | 
			
		||||
@ -228,14 +218,13 @@ files = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "django"
 | 
			
		||||
version = "5.1.5"
 | 
			
		||||
version = "5.1.3"
 | 
			
		||||
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.10"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "Django-5.1.5-py3-none-any.whl", hash = "sha256:c46eb936111fffe6ec4bc9930035524a8be98ec2f74d8a0ff351226a3e52f459"},
 | 
			
		||||
    {file = "Django-5.1.5.tar.gz", hash = "sha256:19bbca786df50b9eca23cee79d495facf55c8f5c54c529d9bf1fe7b5ea086af3"},
 | 
			
		||||
    {file = "Django-5.1.3-py3-none-any.whl", hash = "sha256:8b38a9a12da3ae00cb0ba72da985ec4b14de6345046b1e174b1fd7254398f818"},
 | 
			
		||||
    {file = "Django-5.1.3.tar.gz", hash = "sha256:c0fa0e619c39325a169208caef234f90baa925227032ad3f44842ba14d75234a"},
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.dependencies]
 | 
			
		||||
@ -253,7 +242,6 @@ version = "1.3.0"
 | 
			
		||||
description = "Bringing component based design to Django templates."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "<4,>=3.8"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "django_cotton-1.3.0-py3-none-any.whl", hash = "sha256:a23f29b759c43423e2f901352c0810388839cc412f6985614153c6ccfcfc2595"},
 | 
			
		||||
    {file = "django_cotton-1.3.0.tar.gz", hash = "sha256:8f4a15dd55c8ee9182cf7234c228ea45d9fcdec1de125221bce8d05af035730a"},
 | 
			
		||||
@ -268,7 +256,6 @@ version = "4.4.6"
 | 
			
		||||
description = "A configurable set of panels that display various debug information about the current request/response."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45"},
 | 
			
		||||
    {file = "django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044"},
 | 
			
		||||
@ -284,7 +271,6 @@ version = "3.2.3"
 | 
			
		||||
description = "Extensions for Django"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.6"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"},
 | 
			
		||||
    {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"},
 | 
			
		||||
@ -299,7 +285,6 @@ version = "1.21.0"
 | 
			
		||||
description = "Extensions for using Django with htmx."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.9"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:64bc31463017a80552b767bc216ee5700248fa72e7ccd2963495e69afbdb6abe"},
 | 
			
		||||
    {file = "django_htmx-1.21.0.tar.gz", hash = "sha256:6ed3b42effd5980f22e68f36cd14ee4311bff3b6cb8435a89e27f45995691572"},
 | 
			
		||||
@ -315,7 +300,6 @@ version = "3.2"
 | 
			
		||||
description = "Pickled object field for Django"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "django-picklefield-3.2.tar.gz", hash = "sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d"},
 | 
			
		||||
    {file = "django_picklefield-3.2-py3-none-any.whl", hash = "sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c"},
 | 
			
		||||
@ -333,7 +317,6 @@ version = "1.7.4"
 | 
			
		||||
description = "A multiprocessing distributed task queue for Django"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "<4,>=3.8"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "django_q2-1.7.4-py3-none-any.whl", hash = "sha256:6eda6d56505822ee5ebc6c4eac1dde726f5dbf20ee9ea7080575535852e2671f"},
 | 
			
		||||
    {file = "django_q2-1.7.4.tar.gz", hash = "sha256:56a3781cc480474fa9c04bbde62445b0a9b4195adc409bd963b8f593b0598c43"},
 | 
			
		||||
@ -354,7 +337,6 @@ version = "24.4"
 | 
			
		||||
description = "django-template-partials"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "django_template_partials-24.4-py2.py3-none-any.whl", hash = "sha256:ee59d3839385d7f648907c3fa8d5923fcd66cd8090f141fe2a1c338b917984e2"},
 | 
			
		||||
    {file = "django_template_partials-24.4.tar.gz", hash = "sha256:25b67301470fc274ecc419e5e5fd4686a5020b1c038fd241a70eb087809034b6"},
 | 
			
		||||
@ -373,7 +355,6 @@ version = "3.0.7"
 | 
			
		||||
description = "Django/Jinja template indenter"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "djhtml-3.0.7.tar.gz", hash = "sha256:558c905b092a0c8afcbed27dea2f50aa6eb853a658b309e4e0f2bb378bdf6178"},
 | 
			
		||||
]
 | 
			
		||||
@ -387,7 +368,6 @@ version = "1.36.1"
 | 
			
		||||
description = "HTML Template Linter and Formatter"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.9"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "djlint-1.36.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef40527fd6cd82cdd18f65a6bf5b486b767d2386f6c21f2ebd60e5d88f487fe8"},
 | 
			
		||||
    {file = "djlint-1.36.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4712de3dea172000a098da6a0cd709d158909b4964ba0f68bee584cef18b4878"},
 | 
			
		||||
@ -430,7 +410,6 @@ version = "0.12.4"
 | 
			
		||||
description = "EditorConfig File Locator and Interpreter for Python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"},
 | 
			
		||||
]
 | 
			
		||||
@ -441,7 +420,6 @@ version = "3.16.1"
 | 
			
		||||
description = "A platform independent file lock."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
 | 
			
		||||
    {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
 | 
			
		||||
@ -458,7 +436,6 @@ version = "3.4.3"
 | 
			
		||||
description = "GraphQL Framework for Python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"},
 | 
			
		||||
    {file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"},
 | 
			
		||||
@ -480,7 +457,6 @@ version = "3.2.2"
 | 
			
		||||
description = "Graphene Django integration"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "graphene-django-3.2.2.tar.gz", hash = "sha256:059ccf25d9a5159f28d7ebf1a648c993ab34deb064e80b70ca096aa22a609556"},
 | 
			
		||||
    {file = "graphene_django-3.2.2-py2.py3-none-any.whl", hash = "sha256:0fd95c8c1cbe77ae2a5940045ce276803c3acbf200a156731e0c730f2776ae2c"},
 | 
			
		||||
@ -505,7 +481,6 @@ version = "3.2.5"
 | 
			
		||||
description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "<4,>=3.6"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a"},
 | 
			
		||||
    {file = "graphql_core-3.2.5.tar.gz", hash = "sha256:e671b90ed653c808715645e3998b7ab67d382d55467b7e2978549111bbabf8d5"},
 | 
			
		||||
@ -517,7 +492,6 @@ version = "3.2.0"
 | 
			
		||||
description = "Relay library for graphql-core"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.6,<4"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"},
 | 
			
		||||
    {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"},
 | 
			
		||||
@ -532,7 +506,6 @@ version = "22.0.0"
 | 
			
		||||
description = "WSGI HTTP Server for UNIX"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"},
 | 
			
		||||
    {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"},
 | 
			
		||||
@ -554,7 +527,6 @@ version = "0.14.0"
 | 
			
		||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
 | 
			
		||||
    {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
 | 
			
		||||
@ -566,7 +538,6 @@ version = "2.6.2"
 | 
			
		||||
description = "File identification library for Python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.9"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"},
 | 
			
		||||
    {file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"},
 | 
			
		||||
@ -581,7 +552,6 @@ version = "3.10"
 | 
			
		||||
description = "Internationalized Domain Names in Applications (IDNA)"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.6"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
 | 
			
		||||
    {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
 | 
			
		||||
@ -596,7 +566,6 @@ version = "2.0.0"
 | 
			
		||||
description = "brain-dead simple config-ini parsing"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
 | 
			
		||||
    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
 | 
			
		||||
@ -608,7 +577,6 @@ version = "5.13.2"
 | 
			
		||||
description = "A Python utility / library to sort Python imports."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8.0"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
 | 
			
		||||
    {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
 | 
			
		||||
@ -623,7 +591,6 @@ version = "1.15.1"
 | 
			
		||||
description = "JavaScript unobfuscator and beautifier."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"},
 | 
			
		||||
]
 | 
			
		||||
@ -638,7 +605,6 @@ version = "0.9.25"
 | 
			
		||||
description = "A Python implementation of the JSON5 data format."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"},
 | 
			
		||||
    {file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"},
 | 
			
		||||
@ -650,7 +616,6 @@ version = "3.7"
 | 
			
		||||
description = "Python implementation of John Gruber's Markdown."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"},
 | 
			
		||||
    {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"},
 | 
			
		||||
@ -666,7 +631,6 @@ version = "1.13.0"
 | 
			
		||||
description = "Optional static typing for Python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"},
 | 
			
		||||
    {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"},
 | 
			
		||||
@ -719,7 +683,6 @@ version = "1.0.0"
 | 
			
		||||
description = "Type system extensions for programs checked with the mypy type checker."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.5"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
 | 
			
		||||
    {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
 | 
			
		||||
@ -731,7 +694,6 @@ version = "1.9.1"
 | 
			
		||||
description = "Node.js virtual environment builder"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
 | 
			
		||||
    {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
 | 
			
		||||
@ -743,7 +705,6 @@ version = "24.2"
 | 
			
		||||
description = "Core utilities for Python packages"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
 | 
			
		||||
    {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
 | 
			
		||||
@ -755,7 +716,6 @@ version = "0.12.1"
 | 
			
		||||
description = "Utility library for gitignore style pattern matching of file paths."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
 | 
			
		||||
    {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
 | 
			
		||||
@ -767,7 +727,6 @@ version = "4.3.6"
 | 
			
		||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
 | 
			
		||||
    {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
 | 
			
		||||
@ -784,7 +743,6 @@ version = "1.5.0"
 | 
			
		||||
description = "plugin and hook calling mechanisms for python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
 | 
			
		||||
    {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
 | 
			
		||||
@ -800,7 +758,6 @@ version = "3.8.0"
 | 
			
		||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.9"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
 | 
			
		||||
    {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
 | 
			
		||||
@ -819,7 +776,6 @@ version = "2.3"
 | 
			
		||||
description = "Promises/A+ implementation for Python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"},
 | 
			
		||||
]
 | 
			
		||||
@ -836,7 +792,6 @@ version = "8.3.3"
 | 
			
		||||
description = "pytest: simple powerful testing with Python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
 | 
			
		||||
    {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
 | 
			
		||||
@ -857,7 +812,6 @@ version = "2.9.0.post0"
 | 
			
		||||
description = "Extensions to the standard Python datetime module"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
 | 
			
		||||
    {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
 | 
			
		||||
@ -872,7 +826,6 @@ version = "2024.2"
 | 
			
		||||
description = "World timezone definitions, modern and historical"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
 | 
			
		||||
    {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
 | 
			
		||||
@ -884,7 +837,6 @@ version = "6.0.2"
 | 
			
		||||
description = "YAML parser and emitter for Python"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
 | 
			
		||||
    {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
 | 
			
		||||
@ -947,7 +899,6 @@ version = "2024.11.6"
 | 
			
		||||
description = "Alternative regular expression module, to replace re."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"},
 | 
			
		||||
    {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"},
 | 
			
		||||
@ -1051,7 +1002,6 @@ version = "2.32.3"
 | 
			
		||||
description = "Python HTTP for Humans."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
 | 
			
		||||
    {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
 | 
			
		||||
@ -1073,7 +1023,6 @@ version = "1.16.0"
 | 
			
		||||
description = "Python 2 and 3 compatibility utilities"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
 | 
			
		||||
    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 | 
			
		||||
@ -1085,7 +1034,6 @@ version = "0.5.1"
 | 
			
		||||
description = "A non-validating SQL parser."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"},
 | 
			
		||||
    {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"},
 | 
			
		||||
@ -1101,7 +1049,6 @@ version = "1.3"
 | 
			
		||||
description = "The most basic Text::Unidecode port"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = "*"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
 | 
			
		||||
    {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
 | 
			
		||||
@ -1113,7 +1060,6 @@ version = "4.67.0"
 | 
			
		||||
description = "Fast, Extensible Progress Meter"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be"},
 | 
			
		||||
    {file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"},
 | 
			
		||||
@ -1135,7 +1081,6 @@ version = "4.12.2"
 | 
			
		||||
description = "Backported and Experimental Type Hints for Python 3.8+"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
 | 
			
		||||
    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
 | 
			
		||||
@ -1147,8 +1092,6 @@ version = "2024.2"
 | 
			
		||||
description = "Provider of IANA time zone data"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=2"
 | 
			
		||||
groups = ["main", "dev"]
 | 
			
		||||
markers = "sys_platform == \"win32\""
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
 | 
			
		||||
    {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
 | 
			
		||||
@ -1160,7 +1103,6 @@ version = "2.2.3"
 | 
			
		||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
 | 
			
		||||
    {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
 | 
			
		||||
@ -1178,7 +1120,6 @@ version = "0.30.6"
 | 
			
		||||
description = "The lightning-fast ASGI server."
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["main"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"},
 | 
			
		||||
    {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"},
 | 
			
		||||
@ -1193,14 +1134,13 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "virtualenv"
 | 
			
		||||
version = "20.29.1"
 | 
			
		||||
version = "20.27.1"
 | 
			
		||||
description = "Virtual Python Environment builder"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.8"
 | 
			
		||||
groups = ["dev"]
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779"},
 | 
			
		||||
    {file = "virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35"},
 | 
			
		||||
    {file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"},
 | 
			
		||||
    {file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"},
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.dependencies]
 | 
			
		||||
@ -1213,6 +1153,6 @@ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "s
 | 
			
		||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
 | 
			
		||||
 | 
			
		||||
[metadata]
 | 
			
		||||
lock-version = "2.1"
 | 
			
		||||
lock-version = "2.0"
 | 
			
		||||
python-versions = "^3.11"
 | 
			
		||||
content-hash = "b5bb46a6591964aec145637cd9a412a681f2cc5e7e4fdd6fd9ecb0fe8724b8e3"
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
 | 
			
		||||
django.setup()
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session
 | 
			
		||||
from games.models import Edition, Game, Platform, Purchase, Session
 | 
			
		||||
 | 
			
		||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
 | 
			
		||||
 | 
			
		||||
@ -21,8 +21,10 @@ class PathWorksTest(TestCase):
 | 
			
		||||
        pl.save()
 | 
			
		||||
        g = Game(name="The Test Game")
 | 
			
		||||
        g.save()
 | 
			
		||||
        e = Edition(game=g, name="The Test Game Edition", platform=pl)
 | 
			
		||||
        e.save()
 | 
			
		||||
        p = Purchase(
 | 
			
		||||
            games=[e],
 | 
			
		||||
            edition=e,
 | 
			
		||||
            platform=pl,
 | 
			
		||||
            date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
 | 
			
		||||
        )
 | 
			
		||||
@ -51,6 +53,11 @@ class PathWorksTest(TestCase):
 | 
			
		||||
        response = self.client.get(url)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_add_edition_returns_200(self):
 | 
			
		||||
        url = reverse("add_edition")
 | 
			
		||||
        response = self.client.get(url)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_add_purchase_returns_200(self):
 | 
			
		||||
        url = reverse("add_purchase")
 | 
			
		||||
        response = self.client.get(url)
 | 
			
		||||
 | 
			
		||||
@ -3,13 +3,14 @@ from datetime import datetime
 | 
			
		||||
from zoneinfo import ZoneInfo
 | 
			
		||||
 | 
			
		||||
import django
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
 | 
			
		||||
django.setup()
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from games.models import Game, Purchase, Session
 | 
			
		||||
from games.models import Edition, Game, Purchase, Session
 | 
			
		||||
 | 
			
		||||
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
 | 
			
		||||
 | 
			
		||||
@ -21,8 +22,10 @@ class FormatDurationTest(TestCase):
 | 
			
		||||
    def test_duration_format(self):
 | 
			
		||||
        g = Game(name="The Test Game")
 | 
			
		||||
        g.save()
 | 
			
		||||
        e = Edition(game=g, name="The Test Game Edition")
 | 
			
		||||
        e.save()
 | 
			
		||||
        p = Purchase(
 | 
			
		||||
            game=g, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
 | 
			
		||||
            edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
 | 
			
		||||
        )
 | 
			
		||||
        p.save()
 | 
			
		||||
        s = Session(
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user