Compare commits
	
		
			64 Commits
		
	
	
		
			filters_fi
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5cc1652002 | |||
| 7cf2180192 | |||
| ad0641f95b | |||
| abdcfdfe64 | |||
| 31daf2efe0 | |||
| 6d53fca910 | |||
| f7e426e030 | |||
| b29e4edd72 | |||
| 3c58851b88 | |||
| 99f3540825 | |||
| 5e778bec30 | |||
| fea9d9784d | |||
| 23b4a7a069 | |||
| 89de85c00d | |||
| d892659132 | |||
| 341e62283b | |||
| 61b6c1c55f | |||
| eeaa02bada | |||
| 9d16bc2546 | |||
| 7a52b59b3d | |||
| 0ce59a8cc6 | |||
| e0dfc0fc3e | |||
| 8cb67ca002 | |||
| be2a01840c | |||
| 612c42ebb7 | |||
| e2255a1c85 | |||
| 0b274b4403 | |||
| ddd75f22b0 | |||
| 843eed64d6 | |||
| 50e7efcfae | |||
| 3e713a7637 | |||
| 2d7342c0d5 | |||
| aba9bc994d | |||
| 967ff7df07 | |||
| 2ab497fd54 | |||
| 34148466c7 | |||
| b22e185d47 | |||
| b2b69339b3 | |||
| 89d1bbdd9e | |||
| 637e3e6493 | |||
| d213a3d35d | |||
| 2f4e16dd54 | |||
| 6f62889e92 | |||
| 4ec808eeec | |||
| 69d27958f3 | |||
| 4ec1cf5f28 | |||
| d936fdc60d | |||
| 2116cfc219 | |||
| 6bd8271291 | |||
| e571feadef | |||
| 23c1ce1f96 | |||
| 33103daebc | |||
| ba6028e43d | |||
| c2853a3ecc | |||
| cd90d60475 | |||
| 11cea2142a | |||
| 24578b64fe | |||
| 13e607f9a7 | |||
| fc0d8db8e8 | |||
| 8acc4f9c5b | |||
| 6b7a96dc06 | |||
| 5c5fd5f26a | |||
| 7181b6472c | |||
| af06d07ee3 | 
| @ -1,20 +0,0 @@ | ||||
| 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"] | ||||
							
								
								
									
										26
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | ||||
| { | ||||
|     // Use IntelliSense to learn about possible attributes. | ||||
|     // Hover to view descriptions of existing attributes. | ||||
|     // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 | ||||
|     "version": "0.2.0", | ||||
|     "configurations": [ | ||||
|         { | ||||
|             "name": "Python Debugger: Current File", | ||||
|             "type": "debugpy", | ||||
|             "request": "launch", | ||||
|             "program": "${file}", | ||||
|             "console": "integratedTerminal" | ||||
|         }, | ||||
|         { | ||||
|             "name": "Python Debugger: Django", | ||||
|             "type": "debugpy", | ||||
|             "request": "launch", | ||||
|             "args": [ | ||||
|                 "runserver" | ||||
|             ], | ||||
|             "django": true, | ||||
|             "autoStartBrowser": false, | ||||
|             "program": "${workspaceFolder}/manage.py" | ||||
|         } | ||||
|     ] | ||||
| } | ||||
| @ -8,6 +8,8 @@ | ||||
| * Add all-time stats | ||||
| * Manage purchases | ||||
| * Automatically convert purchase prices | ||||
| * Add emulated property to sessions | ||||
| * Add today's and last 7 days playtime stats to navbar | ||||
|  | ||||
| ## Improved | ||||
| * mark refunded purchases red on game overview | ||||
|  | ||||
| @ -3,11 +3,13 @@ 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 | ||||
| @ -30,7 +32,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}>" | ||||
| @ -50,6 +52,7 @@ 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: | ||||
| @ -62,17 +65,43 @@ 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) -> str: | ||||
|     if (truncated := truncate(input_string)) != input_string: | ||||
|         return Popover(wrapped_content=truncated, popover_content=input_string) | ||||
| def PopoverTruncated( | ||||
|     input_string: str, | ||||
|     popover_content: str = "", | ||||
|     popover_if_not_truncated: bool = False, | ||||
|     length: int = 30, | ||||
|     ellipsis: str = "…", | ||||
|     endpart: str = "", | ||||
| ) -> str: | ||||
|     """ | ||||
|     Returns `input_string` truncated after `length` of characters | ||||
|     and displays the untruncated text in a popover HTML element. | ||||
|     The truncated text ends in `ellipsis`, and optionally | ||||
|     an always-visible `endpart` can be specified. | ||||
|     `popover_content` can be specified if: | ||||
|     1. It needs to be always displayed regardless if text is truncated. | ||||
|     2. It needs to differ from `input_string`. | ||||
|     """ | ||||
|     if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string: | ||||
|         return Popover( | ||||
|             wrapped_content=truncated, | ||||
|             popover_content=popover_content if popover_content else input_string, | ||||
|         ) | ||||
|     else: | ||||
|         return input_string | ||||
|         if popover_content and popover_if_not_truncated: | ||||
|             return Popover( | ||||
|                 wrapped_content=input_string, | ||||
|                 popover_content=popover_content if popover_content else "", | ||||
|             ) | ||||
|         else: | ||||
|             return input_string | ||||
|  | ||||
|  | ||||
| def A( | ||||
| @ -125,33 +154,14 @@ def Div( | ||||
|     return Component(tag_name="div", attributes=attributes, children=children) | ||||
|  | ||||
|  | ||||
| def Label( | ||||
|     attributes: list[HTMLAttribute] = [], | ||||
|     children: list[HTMLTag] | HTMLTag = [], | ||||
| ): | ||||
|     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, | ||||
|     return Component( | ||||
|         tag_name="input", attributes=attributes + [("type", type)], 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( | ||||
| @ -167,74 +177,6 @@ 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] = [], | ||||
| @ -246,15 +188,83 @@ def Icon( | ||||
|     return result | ||||
|  | ||||
|  | ||||
| def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText: | ||||
|     link = reverse("view_game", args=[int(game_id)]) | ||||
| 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!!") | ||||
|     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 = name or 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), | ||||
|         ], | ||||
|     ) | ||||
| @ -262,21 +272,16 @@ def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeTe | ||||
|     return mark_safe( | ||||
|         A( | ||||
|             url=link, | ||||
|             children=[a_content], | ||||
|         ), | ||||
|             children=[content], | ||||
|         ) | ||||
|         if create_link | ||||
|         else content, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def NameWithPlatformIcon(name: str, platform: str) -> SafeText: | ||||
|     content = Div( | ||||
|         [("class", "inline-flex gap-2 items-center")], | ||||
|         [ | ||||
|             Icon( | ||||
|                 platform.icon, | ||||
|                 [("title", platform.name)], | ||||
|             ), | ||||
|             PopoverTruncated(name), | ||||
|         ], | ||||
| 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", | ||||
|     ) | ||||
|  | ||||
|     return mark_safe(content) | ||||
|  | ||||
| @ -44,9 +44,9 @@ | ||||
|   transition: all 0.2s ease-out; | ||||
| } */ | ||||
|  | ||||
| form label { | ||||
| /* form label { | ||||
|   @apply dark:text-slate-400; | ||||
| } | ||||
| } */ | ||||
|  | ||||
| .responsive-table { | ||||
|   @apply dark:text-white mx-auto table-fixed; | ||||
| @ -90,37 +90,37 @@ form label { | ||||
|   } | ||||
| } | ||||
|  | ||||
| form input, | ||||
| /* form input, | ||||
| select, | ||||
| textarea { | ||||
|   @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; | ||||
| } | ||||
| } */ | ||||
|  | ||||
| form input:disabled, | ||||
| select:disabled, | ||||
| textarea:disabled { | ||||
|   @apply dark:bg-slate-700 dark:text-slate-400; | ||||
|   @apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed; | ||||
| } | ||||
|  | ||||
| .errorlist { | ||||
|   @apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px]; | ||||
| } | ||||
|  | ||||
| @media screen and (min-width: 768px) { | ||||
| /* @media screen and (min-width: 768px) { | ||||
|   form input, | ||||
|   select, | ||||
|   textarea { | ||||
|     width: 300px; | ||||
|   } | ||||
| } | ||||
| } */ | ||||
|  | ||||
| @media screen and (max-width: 768px) { | ||||
| /* @media screen and (max-width: 768px) { | ||||
|   form input, | ||||
|   select, | ||||
|   textarea { | ||||
|     width: 150px; | ||||
|   } | ||||
| } | ||||
| } */ | ||||
|  | ||||
| #button-container button { | ||||
|   @apply mx-1; | ||||
| @ -169,3 +169,27 @@ textarea:disabled { | ||||
|      | ||||
|   }   | ||||
| } */ | ||||
|  | ||||
| label { | ||||
|   @apply dark:text-slate-500; | ||||
| } | ||||
|  | ||||
| [type="text"], [type="password"], [type="datetime-local"], [type="datetime"], [type="date"], [type="number"], select, textarea { | ||||
|   @apply dark:bg-slate-600 dark:text-slate-300; | ||||
| } | ||||
|  | ||||
| [type="submit"] { | ||||
|   @apply dark:text-white font-bold dark:bg-blue-600 px-4 py-2; | ||||
| } | ||||
|  | ||||
| form div label { | ||||
|   @apply dark:text-white; | ||||
| } | ||||
|  | ||||
| form div { | ||||
|   @apply flex flex-col; | ||||
| } | ||||
|  | ||||
| div [type="submit"] { | ||||
|   @apply mt-3; | ||||
| } | ||||
|  | ||||
| @ -13,7 +13,7 @@ durationformat_manual: str = "%H hours" | ||||
|  | ||||
|  | ||||
| def _safe_timedelta(duration: timedelta | int | None): | ||||
|     if duration == None: | ||||
|     if duration is None: | ||||
|         return timedelta(0) | ||||
|     elif isinstance(duration, int): | ||||
|         return timedelta(seconds=duration) | ||||
|  | ||||
							
								
								
									
										112
									
								
								common/utils.py
									
									
									
									
									
								
							
							
						
						
									
										112
									
								
								common/utils.py
									
									
									
									
									
								
							| @ -1,8 +1,13 @@ | ||||
| import operator | ||||
| from dataclasses import dataclass | ||||
| from datetime import date | ||||
| from typing import Any, Generator, TypeVar | ||||
| from functools import reduce, wraps | ||||
| from typing import Any, Callable, Generator, Literal, TypeVar | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.db.models import Model | ||||
| from django.db.models import Q | ||||
| from django.http import HttpRequest | ||||
| from django.shortcuts import redirect | ||||
|  | ||||
|  | ||||
| def safe_division(numerator: int | float, denominator: int | float) -> int | float: | ||||
| @ -37,14 +42,31 @@ 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)]}{ellipsis}") | ||||
|         if len(input_string) > 30 | ||||
|         (f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}") | ||||
|         if len(input_string) > length | ||||
|         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) | ||||
|  | ||||
|  | ||||
| @ -69,15 +91,77 @@ 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) | ||||
| OperatorType = Literal["|", "&"] | ||||
|  | ||||
|  | ||||
| def get_field(model: Model, field_name: str): | ||||
|     field = model._meta.get_field(field_name) | ||||
|     return field | ||||
| @dataclass | ||||
| class FilterEntry: | ||||
|     condition: Q | ||||
|     operator: OperatorType = "&" | ||||
|  | ||||
|  | ||||
| def get_field_type(model: Model, field_name: str): | ||||
|     field = model._meta.get_field(field_name) | ||||
|     return type(field) | ||||
| def build_dynamic_filter( | ||||
|     filters: list[FilterEntry | Q], default_operator: OperatorType = "&" | ||||
| ): | ||||
|     """ | ||||
|     Constructs a Django Q filter from a list of filter conditions. | ||||
|  | ||||
|     Args: | ||||
|         filters (list): A list where each item is either: | ||||
|             - A Q object (default AND logic applied) | ||||
|             - A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND) | ||||
|  | ||||
|     Returns: | ||||
|         Q: A combined Q object that can be passed to Django's filter(). | ||||
|     """ | ||||
|     op_map: dict[OperatorType, Callable[[Q, Q], Q]] = { | ||||
|         "|": operator.or_, | ||||
|         "&": operator.and_, | ||||
|     } | ||||
|  | ||||
|     # Convert all plain Q objects into (Q, "&") for default AND behavior | ||||
|     processed_filters = [ | ||||
|         FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters | ||||
|     ] | ||||
|  | ||||
|     # Reduce with dynamic operators | ||||
|     return reduce( | ||||
|         lambda combined_filters, filter: op_map[filter.operator]( | ||||
|             combined_filters, filter.condition | ||||
|         ), | ||||
|         processed_filters, | ||||
|         Q(), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def redirect_to(default_view: str, *default_args): | ||||
|     """ | ||||
|     A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided. | ||||
|  | ||||
|     :param default_view: The name of the default view to redirect to if 'next' is missing. | ||||
|     :param default_args: Any arguments required for the default view. | ||||
|     """ | ||||
|  | ||||
|     def decorator(view_func): | ||||
|         @wraps(view_func) | ||||
|         def wrapped_view(request: HttpRequest, *args, **kwargs): | ||||
|             next_url = request.GET.get("next") | ||||
|             if not next_url: | ||||
|                 from django.urls import ( | ||||
|                     reverse,  # Import inside function to avoid circular imports | ||||
|                 ) | ||||
|  | ||||
|                 next_url = reverse(default_view, args=default_args) | ||||
|  | ||||
|             response = view_func( | ||||
|                 request, *args, **kwargs | ||||
|             )  # Execute the original view logic | ||||
|             return redirect(next_url) | ||||
|  | ||||
|         return wrapped_view | ||||
|  | ||||
|     return decorator | ||||
|  | ||||
|  | ||||
| def add_next_param_to_url(url: str, nexturl: str) -> str: | ||||
|     return f"{url}?{urlencode({'next': nexturl})}" | ||||
|  | ||||
							
								
								
									
										33
									
								
								contrib/scripts/get_exchange_rates_since_1990.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								contrib/scripts/get_exchange_rates_since_1990.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | ||||
| from datetime import datetime | ||||
|  | ||||
| import requests | ||||
|  | ||||
| url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json" | ||||
| date_format = "%Y%m%d" | ||||
| years = range(2000, datetime.now().year + 1) | ||||
| dates = [ | ||||
|     datetime.strftime(datetime(day=1, month=1, year=year), format=date_format) | ||||
|     for year in years | ||||
| ] | ||||
| for date in dates: | ||||
|     final_url = url.format(date) | ||||
|     year = date[:4] | ||||
|     response = requests.get(final_url) | ||||
|     response.raise_for_status() | ||||
|     data = response.json() | ||||
|     if kurzy := data.get("kurzy"): | ||||
|         with open("output.yaml", mode="a") as o: | ||||
|             rates = [ | ||||
|                 f""" | ||||
| - model: games.exchangerate | ||||
|             fields: | ||||
|             currency_from: {currency_name} | ||||
|             currency_to: CZK | ||||
|             year: {year} | ||||
|             rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)} | ||||
|                 """ | ||||
|                 for currency_name in ["EUR", "USD", "CNY"] | ||||
|                 if kurzy.get(currency_name) | ||||
|             ] | ||||
|             o.writelines(rates) | ||||
|     # time.sleep(0.5) | ||||
							
								
								
									
										65
									
								
								contrib/scripts/merge_exchange_records.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								contrib/scripts/merge_exchange_records.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,65 @@ | ||||
| import sys | ||||
|  | ||||
| import yaml | ||||
|  | ||||
|  | ||||
| def load_yaml(filename): | ||||
|     with open(filename, "r", encoding="utf-8") as file: | ||||
|         return yaml.safe_load(file) or [] | ||||
|  | ||||
|  | ||||
| def save_yaml(filename, data): | ||||
|     with open(filename, "w", encoding="utf-8") as file: | ||||
|         yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False) | ||||
|  | ||||
|  | ||||
| def extract_existing_combinations(data): | ||||
|     return { | ||||
|         ( | ||||
|             entry["fields"]["currency_from"], | ||||
|             entry["fields"]["currency_to"], | ||||
|             entry["fields"]["year"], | ||||
|         ) | ||||
|         for entry in data | ||||
|         if entry["model"] == "games.exchangerate" | ||||
|     } | ||||
|  | ||||
|  | ||||
| def filter_new_entries(existing_combinations, additional_files): | ||||
|     new_entries = [] | ||||
|  | ||||
|     for filename in additional_files: | ||||
|         data = load_yaml(filename) | ||||
|         for entry in data: | ||||
|             if entry["model"] == "games.exchangerate": | ||||
|                 key = ( | ||||
|                     entry["fields"]["currency_from"], | ||||
|                     entry["fields"]["currency_to"], | ||||
|                     entry["fields"]["year"], | ||||
|                 ) | ||||
|                 if key not in existing_combinations: | ||||
|                     new_entries.append(entry) | ||||
|  | ||||
|     return new_entries | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     if len(sys.argv) < 3: | ||||
|         print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]") | ||||
|         sys.exit(1) | ||||
|  | ||||
|     example_file = sys.argv[1] | ||||
|     additional_files = sys.argv[2:] | ||||
|     output_file = "filtered_output.yaml" | ||||
|  | ||||
|     existing_data = load_yaml(example_file) | ||||
|     existing_combinations = extract_existing_combinations(existing_data) | ||||
|  | ||||
|     new_entries = filter_new_entries(existing_combinations, additional_files) | ||||
|  | ||||
|     save_yaml(output_file, new_entries) | ||||
|     print(f"Filtered data saved to {output_file}") | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
| @ -2,7 +2,6 @@ from django.contrib import admin | ||||
|  | ||||
| from games.models import ( | ||||
|     Device, | ||||
|     Edition, | ||||
|     ExchangeRate, | ||||
|     Game, | ||||
|     Platform, | ||||
| @ -15,6 +14,5 @@ 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) | ||||
|  | ||||
							
								
								
									
										80
									
								
								games/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								games/api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,80 @@ | ||||
| from datetime import date, datetime | ||||
| from typing import List | ||||
|  | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils.timezone import now as django_timezone_now | ||||
| from ninja import Field, ModelSchema, NinjaAPI, Router, Schema | ||||
|  | ||||
| from games.models import PlayEvent | ||||
|  | ||||
| api = NinjaAPI() | ||||
| playevent_router = Router() | ||||
|  | ||||
| NOW_FACTORY = django_timezone_now | ||||
|  | ||||
|  | ||||
| class PlayEventIn(Schema): | ||||
|     game_id: int | ||||
|     started: date | None = None | ||||
|     ended: date | None = None | ||||
|     note: str = "" | ||||
|     days_to_finish: int | None = None | ||||
|  | ||||
|  | ||||
| class AutoPlayEventIn(ModelSchema): | ||||
|     class Meta: | ||||
|         model = PlayEvent | ||||
|         fields = ["game", "started", "ended", "note"] | ||||
|  | ||||
|  | ||||
| class UpdatePlayEventIn(Schema): | ||||
|     started: date | None = None | ||||
|     ended: date | None = None | ||||
|     note: str = "" | ||||
|  | ||||
|  | ||||
| class PlayEventOut(Schema): | ||||
|     id: int | ||||
|     game: str = Field(..., alias="game.name") | ||||
|     started: date | None = None | ||||
|     ended: date | None = None | ||||
|     days_to_finish: int | None = None | ||||
|     note: str = "" | ||||
|     updated_at: datetime | ||||
|     created_at: datetime | ||||
|  | ||||
|  | ||||
| @playevent_router.get("/", response=List[PlayEventOut]) | ||||
| def list_playevents(request): | ||||
|     return PlayEvent.objects.all() | ||||
|  | ||||
|  | ||||
| @playevent_router.post("/", response={201: PlayEventOut}) | ||||
| def create_playevent(request, payload: PlayEventIn): | ||||
|     playevent = PlayEvent.objects.create(**payload.dict()) | ||||
|     return playevent | ||||
|  | ||||
|  | ||||
| @playevent_router.get("/{playevent_id}", response=PlayEventOut) | ||||
| def get_playevent(request, playevent_id: int): | ||||
|     playevent = get_object_or_404(PlayEvent, id=playevent_id) | ||||
|     return playevent | ||||
|  | ||||
|  | ||||
| @playevent_router.patch("/{playevent_id}", response=PlayEventOut) | ||||
| def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn): | ||||
|     playevent = get_object_or_404(PlayEvent, id=playevent_id) | ||||
|     for attr, value in payload.dict(exclude_unset=True).items(): | ||||
|         setattr(playevent, attr, value) | ||||
|     playevent.save() | ||||
|     return playevent | ||||
|  | ||||
|  | ||||
| @playevent_router.delete("/{playevent_id}", response={204: None}) | ||||
| def delete_playevent(request, playevent_id: int): | ||||
|     playevent = get_object_or_404(PlayEvent, id=playevent_id) | ||||
|     playevent.delete() | ||||
|     return 204, None | ||||
|  | ||||
|  | ||||
| api.add_router("/playevent", playevent_router) | ||||
| @ -1,9 +1,10 @@ | ||||
| from datetime import timedelta | ||||
| # from datetime import timedelta | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.core.management import call_command | ||||
| from django.db.models.signals import post_migrate | ||||
| from django.utils.timezone import now | ||||
|  | ||||
| # from django.utils.timezone import now | ||||
|  | ||||
|  | ||||
| class GamesConfig(AppConfig): | ||||
| @ -11,20 +12,32 @@ class GamesConfig(AppConfig): | ||||
|     name = "games" | ||||
|  | ||||
|     def ready(self): | ||||
|         import games.signals  # noqa: F401 | ||||
|  | ||||
|         post_migrate.connect(schedule_tasks, sender=self) | ||||
|  | ||||
|  | ||||
| def schedule_tasks(sender, **kwargs): | ||||
|     from django_q.models import Schedule | ||||
|     from django_q.tasks import schedule | ||||
|     # from django_q.models import Schedule | ||||
|     # from django_q.tasks import schedule | ||||
|  | ||||
|     if not Schedule.objects.filter(name="Update converted prices").exists(): | ||||
|         schedule( | ||||
|             "games.tasks.convert_prices", | ||||
|             name="Update converted prices", | ||||
|             schedule_type=Schedule.MINUTES, | ||||
|             next_run=now() + timedelta(seconds=30), | ||||
|         ) | ||||
|     # if not Schedule.objects.filter(name="Update converted prices").exists(): | ||||
|     #     schedule( | ||||
|     #         "games.tasks.convert_prices", | ||||
|     #         name="Update converted prices", | ||||
|     #         schedule_type=Schedule.MINUTES, | ||||
|     #         next_run=now() + timedelta(seconds=30), | ||||
|     #         catchup=False, | ||||
|     #     ) | ||||
|  | ||||
|     # if not Schedule.objects.filter(name="Update price per game").exists(): | ||||
|     #     schedule( | ||||
|     #         "games.tasks.calculate_price_per_game", | ||||
|     #         name="Update price per game", | ||||
|     #         schedule_type=Schedule.MINUTES, | ||||
|     #         next_run=now() + timedelta(seconds=30), | ||||
|     #         catchup=False, | ||||
|     #     ) | ||||
|  | ||||
|     from games.models import ExchangeRate | ||||
|  | ||||
|  | ||||
							
								
								
									
										101
									
								
								games/filters.py
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								games/filters.py
									
									
									
									
									
								
							| @ -1,101 +0,0 @@ | ||||
| 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 | ||||
| @ -110,3 +110,395 @@ | ||||
|     currency_to: CZK | ||||
|     year: 2018 | ||||
|     rate: 3.268 | ||||
| - model: games.exchangerate | ||||
|   pk: 17 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2023 | ||||
|     rate: 3.281 | ||||
| - model: games.exchangerate | ||||
|   pk: 18 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2009 | ||||
|     rate: 26.445 | ||||
| - model: games.exchangerate | ||||
|   pk: 19 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2025 | ||||
|     rate: 3.35 | ||||
| - model: games.exchangerate | ||||
|   pk: 20 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2016 | ||||
|     rate: 27.033 | ||||
| - model: games.exchangerate | ||||
|   pk: 21 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2025 | ||||
|     rate: 25.2021966 | ||||
| - model: games.exchangerate | ||||
|   pk: 22 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2017 | ||||
|     rate: 26.33 | ||||
| - model: games.exchangerate | ||||
|   pk: 23 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2000 | ||||
|     rate: 36.13 | ||||
| - model: games.exchangerate | ||||
|   pk: 24 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2000 | ||||
|     rate: 35.979 | ||||
| - model: games.exchangerate | ||||
|   pk: 25 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2001 | ||||
|     rate: 35.09 | ||||
| - model: games.exchangerate | ||||
|   pk: 26 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2001 | ||||
|     rate: 37.813 | ||||
| - model: games.exchangerate | ||||
|   pk: 27 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2002 | ||||
|     rate: 31.98 | ||||
| - model: games.exchangerate | ||||
|   pk: 28 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2002 | ||||
|     rate: 36.259 | ||||
| - model: games.exchangerate | ||||
|   pk: 29 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2003 | ||||
|     rate: 31.6 | ||||
| - model: games.exchangerate | ||||
|   pk: 30 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2003 | ||||
|     rate: 30.141 | ||||
| - model: games.exchangerate | ||||
|   pk: 31 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2004 | ||||
|     rate: 32.405 | ||||
| - model: games.exchangerate | ||||
|   pk: 32 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2004 | ||||
|     rate: 25.654 | ||||
| - model: games.exchangerate | ||||
|   pk: 33 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2005 | ||||
|     rate: 30.465 | ||||
| - model: games.exchangerate | ||||
|   pk: 34 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2005 | ||||
|     rate: 22.365 | ||||
| - model: games.exchangerate | ||||
|   pk: 35 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2006 | ||||
|     rate: 29.005 | ||||
| - model: games.exchangerate | ||||
|   pk: 36 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2006 | ||||
|     rate: 24.588 | ||||
| - model: games.exchangerate | ||||
|   pk: 37 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2006 | ||||
|     rate: 3.047 | ||||
| - model: games.exchangerate | ||||
|   pk: 38 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2007 | ||||
|     rate: 27.495 | ||||
| - model: games.exchangerate | ||||
|   pk: 39 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2007 | ||||
|     rate: 20.876 | ||||
| - model: games.exchangerate | ||||
|   pk: 40 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2007 | ||||
|     rate: 2.674 | ||||
| - model: games.exchangerate | ||||
|   pk: 41 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2008 | ||||
|     rate: 26.62 | ||||
| - model: games.exchangerate | ||||
|   pk: 42 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2008 | ||||
|     rate: 18.078 | ||||
| - model: games.exchangerate | ||||
|   pk: 43 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2008 | ||||
|     rate: 2.475 | ||||
| - model: games.exchangerate | ||||
|   pk: 44 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2009 | ||||
|     rate: 19.346 | ||||
| - model: games.exchangerate | ||||
|   pk: 45 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2009 | ||||
|     rate: 2.836 | ||||
| - model: games.exchangerate | ||||
|   pk: 46 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2010 | ||||
|     rate: 18.368 | ||||
| - model: games.exchangerate | ||||
|   pk: 47 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2010 | ||||
|     rate: 2.691 | ||||
| - model: games.exchangerate | ||||
|   pk: 48 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2011 | ||||
|     rate: 25.06 | ||||
| - model: games.exchangerate | ||||
|   pk: 49 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2011 | ||||
|     rate: 18.751 | ||||
| - model: games.exchangerate | ||||
|   pk: 50 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2011 | ||||
|     rate: 2.845 | ||||
| - model: games.exchangerate | ||||
|   pk: 51 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2012 | ||||
|     rate: 19.94 | ||||
| - model: games.exchangerate | ||||
|   pk: 52 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2012 | ||||
|     rate: 3.168 | ||||
| - model: games.exchangerate | ||||
|   pk: 53 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2013 | ||||
|     rate: 25.14 | ||||
| - model: games.exchangerate | ||||
|   pk: 54 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2013 | ||||
|     rate: 3.059 | ||||
| - model: games.exchangerate | ||||
|   pk: 55 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2014 | ||||
|     rate: 19.894 | ||||
| - model: games.exchangerate | ||||
|   pk: 56 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2014 | ||||
|     rate: 3.286 | ||||
| - model: games.exchangerate | ||||
|   pk: 57 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2015 | ||||
|     rate: 27.725 | ||||
| - model: games.exchangerate | ||||
|   pk: 58 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2015 | ||||
|     rate: 22.834 | ||||
| - model: games.exchangerate | ||||
|   pk: 59 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2016 | ||||
|     rate: 24.824 | ||||
| - model: games.exchangerate | ||||
|   pk: 60 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2017 | ||||
|     rate: 3.693 | ||||
| - model: games.exchangerate | ||||
|   pk: 61 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2018 | ||||
|     rate: 25.54 | ||||
| - model: games.exchangerate | ||||
|   pk: 62 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2018 | ||||
|     rate: 21.291 | ||||
| - model: games.exchangerate | ||||
|   pk: 63 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2019 | ||||
|     rate: 25.725 | ||||
| - model: games.exchangerate | ||||
|   pk: 64 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2020 | ||||
|     rate: 25.41 | ||||
| - model: games.exchangerate | ||||
|   pk: 65 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2020 | ||||
|     rate: 22.621 | ||||
| - model: games.exchangerate | ||||
|   pk: 66 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2021 | ||||
|     rate: 26.245 | ||||
| - model: games.exchangerate | ||||
|   pk: 67 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2021 | ||||
|     rate: 21.387 | ||||
| - model: games.exchangerate | ||||
|   pk: 68 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2021 | ||||
|     rate: 3.273 | ||||
| - model: games.exchangerate | ||||
|   pk: 69 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2022 | ||||
|     rate: 21.951 | ||||
| - model: games.exchangerate | ||||
|   pk: 70 | ||||
|   fields: | ||||
|     currency_from: CNY | ||||
|     currency_to: CZK | ||||
|     year: 2022 | ||||
|     rate: 3.458 | ||||
| - model: games.exchangerate | ||||
|   pk: 71 | ||||
|   fields: | ||||
|     currency_from: EUR | ||||
|     currency_to: CZK | ||||
|     year: 2023 | ||||
|     rate: 24.115 | ||||
| - model: games.exchangerate | ||||
|   pk: 72 | ||||
|   fields: | ||||
|     currency_from: USD | ||||
|     currency_to: CZK | ||||
|     year: 2025 | ||||
|     rate: 24.237 | ||||
|  | ||||
							
								
								
									
										164
									
								
								games/forms.py
									
									
									
									
									
								
							
							
						
						
									
										164
									
								
								games/forms.py
									
									
									
									
									
								
							| @ -1,8 +1,17 @@ | ||||
| from django import forms | ||||
| from django.db import transaction | ||||
| from django.urls import reverse | ||||
|  | ||||
| from common.utils import safe_getattr | ||||
| from games.models import Device, Edition, Game, Platform, Purchase, Session | ||||
| from games.models import ( | ||||
|     Device, | ||||
|     Game, | ||||
|     GameStatusChange, | ||||
|     Platform, | ||||
|     PlayEvent, | ||||
|     Purchase, | ||||
|     Session, | ||||
| ) | ||||
|  | ||||
| custom_date_widget = forms.DateInput(attrs={"type": "date"}) | ||||
| custom_datetime_widget = forms.DateTimeInput( | ||||
| @ -11,17 +20,37 @@ custom_datetime_widget = forms.DateTimeInput( | ||||
| autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) | ||||
|  | ||||
|  | ||||
| class MultipleGameChoiceField(forms.ModelMultipleChoiceField): | ||||
|     def label_from_instance(self, obj) -> str: | ||||
|         return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" | ||||
|  | ||||
|  | ||||
| class SingleGameChoiceField(forms.ModelChoiceField): | ||||
|     def label_from_instance(self, obj) -> str: | ||||
|         return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" | ||||
|  | ||||
|  | ||||
| class SessionForm(forms.ModelForm): | ||||
|     # 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"), | ||||
|     game = SingleGameChoiceField( | ||||
|         queryset=Game.objects.order_by("sort_name"), | ||||
|         widget=forms.Select(attrs={"autofocus": "autofocus"}), | ||||
|     ) | ||||
|  | ||||
|     duration_manual = forms.DurationField( | ||||
|         required=False, | ||||
|         widget=forms.TextInput( | ||||
|             attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""} | ||||
|         ), | ||||
|         label="Manual duration", | ||||
|     ) | ||||
|     device = forms.ModelChoiceField(queryset=Device.objects.order_by("name")) | ||||
|  | ||||
|     mark_as_played = forms.BooleanField( | ||||
|         required=False, | ||||
|         initial={"mark_as_played": True}, | ||||
|         label="Set game status to Played if Unplayed", | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         widgets = { | ||||
|             "timestamp_start": custom_datetime_widget, | ||||
| @ -29,21 +58,30 @@ class SessionForm(forms.ModelForm): | ||||
|         } | ||||
|         model = Session | ||||
|         fields = [ | ||||
|             "purchase", | ||||
|             "game", | ||||
|             "timestamp_start", | ||||
|             "timestamp_end", | ||||
|             "duration_manual", | ||||
|             "emulated", | ||||
|             "device", | ||||
|             "note", | ||||
|             "mark_as_played", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class EditionChoiceField(forms.ModelChoiceField): | ||||
|     def label_from_instance(self, obj) -> str: | ||||
|         return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" | ||||
|     def save(self, commit=True): | ||||
|         session = super().save(commit=False) | ||||
|         if self.cleaned_data.get("mark_as_played"): | ||||
|             game_instance = session.game | ||||
|             if game_instance.status == "u": | ||||
|                 game_instance.status = "p" | ||||
|             if commit: | ||||
|                 game_instance.save() | ||||
|         if commit: | ||||
|             session.save() | ||||
|         return session | ||||
|  | ||||
|  | ||||
| class IncludePlatformSelect(forms.Select): | ||||
| class IncludePlatformSelect(forms.SelectMultiple): | ||||
|     def create_option(self, name, value, *args, **kwargs): | ||||
|         option = super().create_option(name, value, *args, **kwargs) | ||||
|         if platform_id := safe_getattr(value, "instance.platform.id"): | ||||
| @ -56,44 +94,50 @@ class PurchaseForm(forms.ModelForm): | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|         # Automatically update related_purchase <select/> | ||||
|         # to only include purchases of the selected edition. | ||||
|         related_purchase_by_edition_url = reverse("related_purchase_by_edition") | ||||
|         self.fields["edition"].widget.attrs.update( | ||||
|         # to only include purchases of the selected game. | ||||
|         related_purchase_by_game_url = reverse("related_purchase_by_game") | ||||
|         self.fields["games"].widget.attrs.update( | ||||
|             { | ||||
|                 "hx-trigger": "load, click", | ||||
|                 "hx-get": related_purchase_by_edition_url, | ||||
|                 "hx-get": related_purchase_by_game_url, | ||||
|                 "hx-target": "#id_related_purchase", | ||||
|                 "hx-swap": "outerHTML", | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     edition = EditionChoiceField( | ||||
|         queryset=Edition.objects.order_by("sort_name"), | ||||
|     games = MultipleGameChoiceField( | ||||
|         queryset=Game.objects.order_by("sort_name"), | ||||
|         widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), | ||||
|     ) | ||||
|     platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) | ||||
|     related_purchase = forms.ModelChoiceField( | ||||
|         queryset=Purchase.objects.filter(type=Purchase.GAME).order_by( | ||||
|             "edition__sort_name" | ||||
|         ), | ||||
|         queryset=Purchase.objects.filter(type=Purchase.GAME), | ||||
|         required=False, | ||||
|     ) | ||||
|  | ||||
|     price_currency = forms.CharField( | ||||
|         widget=forms.TextInput( | ||||
|             attrs={ | ||||
|                 "x-mask": "aaa", | ||||
|                 "placeholder": "CZK", | ||||
|                 "x-data": "", | ||||
|                 "class": "uppercase", | ||||
|             } | ||||
|         ), | ||||
|         label="Currency", | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         widgets = { | ||||
|             "date_purchased": custom_date_widget, | ||||
|             "date_refunded": custom_date_widget, | ||||
|             "date_finished": custom_date_widget, | ||||
|             "date_dropped": custom_date_widget, | ||||
|         } | ||||
|         model = Purchase | ||||
|         fields = [ | ||||
|             "edition", | ||||
|             "games", | ||||
|             "platform", | ||||
|             "date_purchased", | ||||
|             "date_refunded", | ||||
|             "date_finished", | ||||
|             "date_dropped", | ||||
|             "infinite", | ||||
|             "price", | ||||
|             "price_currency", | ||||
| @ -140,24 +184,23 @@ class GameModelChoiceField(forms.ModelChoiceField): | ||||
|         return obj.sort_name | ||||
|  | ||||
|  | ||||
| class EditionForm(forms.ModelForm): | ||||
|     game = GameModelChoiceField( | ||||
|         queryset=Game.objects.order_by("sort_name"), | ||||
|         widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}), | ||||
|     ) | ||||
| class GameForm(forms.ModelForm): | ||||
|     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", "year_released", "wikidata"] | ||||
|         fields = [ | ||||
|             "name", | ||||
|             "sort_name", | ||||
|             "platform", | ||||
|             "original_year_released", | ||||
|             "year_released", | ||||
|             "status", | ||||
|             "mastered", | ||||
|             "wikidata", | ||||
|         ] | ||||
|         widgets = {"name": autofocus_input_widget} | ||||
|  | ||||
|  | ||||
| @ -177,3 +220,48 @@ class DeviceForm(forms.ModelForm): | ||||
|         model = Device | ||||
|         fields = ["name", "type"] | ||||
|         widgets = {"name": autofocus_input_widget} | ||||
|  | ||||
|  | ||||
| class PlayEventForm(forms.ModelForm): | ||||
|     game = GameModelChoiceField( | ||||
|         queryset=Game.objects.order_by("sort_name"), | ||||
|         widget=forms.Select(attrs={"autofocus": "autofocus"}), | ||||
|     ) | ||||
|  | ||||
|     mark_as_finished = forms.BooleanField( | ||||
|         required=False, | ||||
|         initial={"mark_as_finished": True}, | ||||
|         label="Set game status to Finished", | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = PlayEvent | ||||
|         fields = ["game", "started", "ended", "note", "mark_as_finished"] | ||||
|         widgets = { | ||||
|             "started": custom_date_widget, | ||||
|             "ended": custom_date_widget, | ||||
|         } | ||||
|  | ||||
|     def save(self, commit=True): | ||||
|         with transaction.atomic(): | ||||
|             session = super().save(commit=False) | ||||
|             if self.cleaned_data.get("mark_as_finished"): | ||||
|                 game_instance = session.game | ||||
|                 game_instance.status = "f" | ||||
|                 game_instance.save() | ||||
|             session.save() | ||||
|         return session | ||||
|  | ||||
|  | ||||
| class GameStatusChangeForm(forms.ModelForm): | ||||
|     class Meta: | ||||
|         model = GameStatusChange | ||||
|         fields = [ | ||||
|             "game", | ||||
|             "old_status", | ||||
|             "new_status", | ||||
|             "timestamp", | ||||
|         ] | ||||
|         widgets = { | ||||
|             "timestamp": custom_datetime_widget, | ||||
|         } | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| 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 | ||||
|  | ||||
| @ -1,11 +0,0 @@ | ||||
| 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,5 +1,6 @@ | ||||
| # Generated by Django 4.1.4 on 2023-01-02 18:27 | ||||
| # Generated by Django 5.1.5 on 2025-01-29 21:26 | ||||
|  | ||||
| import datetime | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
| @ -8,94 +9,96 @@ class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [] | ||||
|     dependencies = [ | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="Game", | ||||
|             name='Device', | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "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)), | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('name', models.CharField(max_length=255)), | ||||
|                 ('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)), | ||||
|                 ('created_at', models.DateTimeField(auto_now_add=True)), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="Platform", | ||||
|             name='Platform', | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.BigAutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.CharField(max_length=255)), | ||||
|                 ("group", models.CharField(max_length=255)), | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('name', models.CharField(max_length=255)), | ||||
|                 ('group', models.CharField(blank=True, default=None, max_length=255, null=True)), | ||||
|                 ('icon', models.SlugField(blank=True)), | ||||
|                 ('created_at', models.DateTimeField(auto_now_add=True)), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="Purchase", | ||||
|             name='ExchangeRate', | ||||
|             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)), | ||||
|                 ( | ||||
|                     "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", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ('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')), | ||||
|             ], | ||||
|         ), | ||||
|         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()), | ||||
|                 ("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", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('timestamp_start', models.DateTimeField()), | ||||
|                 ('timestamp_end', models.DateTimeField(blank=True, null=True)), | ||||
|                 ('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)), | ||||
|                 ('duration_calculated', models.DurationField(blank=True, null=True)), | ||||
|                 ('note', models.TextField(blank=True, null=True)), | ||||
|                 ('emulated', models.BooleanField(default=False)), | ||||
|                 ('created_at', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('modified_at', models.DateTimeField(auto_now=True)), | ||||
|                 ('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')), | ||||
|                 ('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'get_latest_by': 'timestamp_start', | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
| @ -1,22 +0,0 @@ | ||||
| # 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 | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										18
									
								
								games/migrations/0002_purchase_price_per_game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0002_purchase_price_per_game.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| # Generated by Django 5.1.5 on 2025-01-30 11:04 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0001_initial'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='purchase', | ||||
|             name='price_per_game', | ||||
|             field=models.FloatField(null=True), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,23 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										18
									
								
								games/migrations/0003_purchase_updated_at.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0003_purchase_updated_at.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| # Generated by Django 5.1.5 on 2025-01-30 11:12 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0002_purchase_price_per_game'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='purchase', | ||||
|             name='updated_at', | ||||
|             field=models.DateTimeField(auto_now=True), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,22 +0,0 @@ | ||||
| # 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 | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										28
									
								
								games/migrations/0004_purchase_num_purchases.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								games/migrations/0004_purchase_num_purchases.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| # Generated by Django 5.1.5 on 2025-01-30 11:57 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| from django.db.models import Count | ||||
|  | ||||
|  | ||||
| def initialize_num_purchases(apps, schema_editor): | ||||
|     Purchase = apps.get_model("games", "Purchase") | ||||
|     purchases = Purchase.objects.annotate(num_games=Count("games")) | ||||
|  | ||||
|     for purchase in purchases: | ||||
|         purchase.num_purchases = purchase.num_games | ||||
|         purchase.save(update_fields=["num_purchases"]) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("games", "0003_purchase_updated_at"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="purchase", | ||||
|             name="num_purchases", | ||||
|             field=models.IntegerField(default=0), | ||||
|         ), | ||||
|         migrations.RunPython(initialize_num_purchases), | ||||
|     ] | ||||
| @ -1,35 +0,0 @@ | ||||
| # 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, | ||||
|         ) | ||||
|     ] | ||||
							
								
								
									
										38
									
								
								games/migrations/0005_game_mastered_game_status.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								games/migrations/0005_game_mastered_game_status.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| # Generated by Django 5.1.5 on 2025-02-01 19:18 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| def set_finished_status(apps, schema_editor): | ||||
|     Game = apps.get_model("games", "Game") | ||||
|     Game.objects.filter(purchases__date_finished__isnull=False).update(status="f") | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("games", "0004_purchase_num_purchases"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="game", | ||||
|             name="mastered", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="game", | ||||
|             name="status", | ||||
|             field=models.CharField( | ||||
|                 choices=[ | ||||
|                     ("u", "Unplayed"), | ||||
|                     ("p", "Played"), | ||||
|                     ("f", "Finished"), | ||||
|                     ("r", "Retired"), | ||||
|                     ("a", "Abandoned"), | ||||
|                 ], | ||||
|                 default="u", | ||||
|                 max_length=1, | ||||
|             ), | ||||
|         ), | ||||
|         migrations.RunPython(set_finished_status), | ||||
|     ] | ||||
| @ -0,0 +1,59 @@ | ||||
| # Generated by Django 5.1.5 on 2025-03-01 12:52 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0005_game_mastered_game_status'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='game', | ||||
|             name='sort_name', | ||||
|             field=models.CharField(blank=True, default='', max_length=255), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='game', | ||||
|             name='wikidata', | ||||
|             field=models.CharField(blank=True, default='', max_length=50), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='platform', | ||||
|             name='group', | ||||
|             field=models.CharField(blank=True, default='', max_length=255), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='purchase', | ||||
|             name='converted_currency', | ||||
|             field=models.CharField(blank=True, default='', max_length=3), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='purchase', | ||||
|             name='games', | ||||
|             field=models.ManyToManyField(related_name='purchases', to='games.game'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='purchase', | ||||
|             name='name', | ||||
|             field=models.CharField(blank=True, default='', max_length=255), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='purchase', | ||||
|             name='related_purchase', | ||||
|             field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='session', | ||||
|             name='game', | ||||
|             field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='session', | ||||
|             name='note', | ||||
|             field=models.TextField(blank=True, default=''), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,35 +0,0 @@ | ||||
| # 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, | ||||
|         ) | ||||
|     ] | ||||
| @ -1,35 +0,0 @@ | ||||
| # 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" | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										18
									
								
								games/migrations/0007_game_updated_at.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/migrations/0007_game_updated_at.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| # Generated by Django 5.1.5 on 2025-03-17 07:36 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='game', | ||||
|             name='updated_at', | ||||
|             field=models.DateTimeField(auto_now=True), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,41 +0,0 @@ | ||||
| # 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" | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,190 @@ | ||||
| # Generated by Django 5.1.7 on 2025-03-19 13:11 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| import django.db.models.expressions | ||||
| from django.db import migrations, models | ||||
| from django.db.models import F, Min | ||||
|  | ||||
|  | ||||
| def copy_year_released(apps, schema_editor): | ||||
|     Game = apps.get_model("games", "Game") | ||||
|     Game.objects.update(original_year_released=F("year_released")) | ||||
|  | ||||
|  | ||||
| def set_abandoned_status(apps, schema_editor): | ||||
|     Game = apps.get_model("games", "Game") | ||||
|     Game = apps.get_model("games", "Game") | ||||
|     PlayEvent = apps.get_model("games", "PlayEvent") | ||||
|  | ||||
|     Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a") | ||||
|     Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a") | ||||
|  | ||||
|     finished = Game.objects.filter(purchases__date_finished__isnull=False) | ||||
|  | ||||
|     for game in finished: | ||||
|         for purchase in game.purchases.all(): | ||||
|             first_session = game.sessions.filter( | ||||
|                 timestamp_start__gte=purchase.date_purchased | ||||
|             ).aggregate(Min("timestamp_start"))["timestamp_start__min"] | ||||
|             first_session_date = first_session.date() if first_session else None | ||||
|             if purchase.date_finished: | ||||
|                 play_event = PlayEvent( | ||||
|                     game=game, | ||||
|                     started=first_session_date | ||||
|                     if first_session_date | ||||
|                     else purchase.date_purchased, | ||||
|                     ended=purchase.date_finished, | ||||
|                 ) | ||||
|                 play_event.save() | ||||
|  | ||||
|  | ||||
| def create_game_status_changes(apps, schema_editor): | ||||
|     Game = apps.get_model("games", "Game") | ||||
|     GameStatusChange = apps.get_model("games", "GameStatusChange") | ||||
|  | ||||
|     # if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start | ||||
|     for game in Game.objects.filter(sessions__isnull=False).distinct(): | ||||
|         if game.sessions.exists(): | ||||
|             earliest_session = game.sessions.earliest() | ||||
|             GameStatusChange.objects.create( | ||||
|                 game=game, | ||||
|                 old_status="u", | ||||
|                 new_status="p", | ||||
|                 timestamp=earliest_session.timestamp_start, | ||||
|             ) | ||||
|  | ||||
|     for game in Game.objects.filter(purchases__date_dropped__isnull=False): | ||||
|         GameStatusChange.objects.create( | ||||
|             game=game, | ||||
|             old_status="p", | ||||
|             new_status="a", | ||||
|             timestamp=game.purchases.first().date_dropped, | ||||
|         ) | ||||
|  | ||||
|     for game in Game.objects.filter(purchases__date_refunded__isnull=False): | ||||
|         GameStatusChange.objects.create( | ||||
|             game=game, | ||||
|             old_status="p", | ||||
|             new_status="a", | ||||
|             timestamp=game.purchases.first().date_refunded, | ||||
|         ) | ||||
|  | ||||
|     # check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date | ||||
|     # consider only the first playevent | ||||
|     for game in Game.objects.filter(playevents__isnull=False): | ||||
|         first_playevent = game.playevents.first() | ||||
|         GameStatusChange.objects.create( | ||||
|             game=game, | ||||
|             old_status="p", | ||||
|             new_status="f", | ||||
|             timestamp=first_playevent.ended, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("games", "0007_game_updated_at"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="game", | ||||
|             name="original_year_released", | ||||
|             field=models.IntegerField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.RunPython(copy_year_released), | ||||
|         migrations.CreateModel( | ||||
|             name="GameStatusChange", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.BigAutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "old_status", | ||||
|                     models.CharField( | ||||
|                         blank=True, | ||||
|                         choices=[ | ||||
|                             ("u", "Unplayed"), | ||||
|                             ("p", "Played"), | ||||
|                             ("f", "Finished"), | ||||
|                             ("r", "Retired"), | ||||
|                             ("a", "Abandoned"), | ||||
|                         ], | ||||
|                         max_length=1, | ||||
|                         null=True, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "new_status", | ||||
|                     models.CharField( | ||||
|                         choices=[ | ||||
|                             ("u", "Unplayed"), | ||||
|                             ("p", "Played"), | ||||
|                             ("f", "Finished"), | ||||
|                             ("r", "Retired"), | ||||
|                             ("a", "Abandoned"), | ||||
|                         ], | ||||
|                         max_length=1, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("timestamp", models.DateTimeField(null=True)), | ||||
|                 ( | ||||
|                     "game", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         related_name="status_changes", | ||||
|                         to="games.game", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "ordering": ["-timestamp"], | ||||
|             }, | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="PlayEvent", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.BigAutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("started", models.DateField(blank=True, null=True)), | ||||
|                 ("ended", models.DateField(blank=True, null=True)), | ||||
|                 ( | ||||
|                     "days_to_finish", | ||||
|                     models.GeneratedField( | ||||
|                         db_persist=True, | ||||
|                         expression=django.db.models.expressions.RawSQL( | ||||
|                             "\n            COALESCE(\n                CASE \n                    WHEN date(ended) = date(started) THEN 1\n                    ELSE julianday(ended) - julianday(started)\n                END, 0\n            )\n            ", | ||||
|                             [], | ||||
|                         ), | ||||
|                         output_field=models.IntegerField(), | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("note", models.CharField(blank=True, default="", max_length=255)), | ||||
|                 ("created_at", models.DateTimeField(auto_now_add=True)), | ||||
|                 ("updated_at", models.DateTimeField(auto_now=True)), | ||||
|                 ( | ||||
|                     "game", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         related_name="playevents", | ||||
|                         to="games.game", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.RunPython(set_abandoned_status), | ||||
|         migrations.RunPython(create_game_status_changes), | ||||
|     ] | ||||
| @ -1,34 +0,0 @@ | ||||
| # 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)] | ||||
| @ -0,0 +1,21 @@ | ||||
| # Generated by Django 5.1.7 on 2025-03-20 11:35 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0008_game_original_year_released_gamestatuschange_and_more'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='purchase', | ||||
|             name='date_dropped', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='purchase', | ||||
|             name='date_finished', | ||||
|         ), | ||||
|     ] | ||||
| @ -1,21 +0,0 @@ | ||||
| # 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" | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										17
									
								
								games/migrations/0010_remove_purchase_price_per_game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								games/migrations/0010_remove_purchase_price_per_game.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| # Generated by Django 5.1.7 on 2025-03-22 17:46 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0009_remove_purchase_date_dropped_and_more'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='purchase', | ||||
|             name='price_per_game', | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										20
									
								
								games/migrations/0011_purchase_price_per_game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								games/migrations/0011_purchase_price_per_game.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| # Generated by Django 5.1.7 on 2025-03-22 17:46 | ||||
|  | ||||
| import django.db.models.expressions | ||||
| import django.db.models.functions.comparison | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0010_remove_purchase_price_per_game'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='purchase', | ||||
|             name='price_per_game', | ||||
|             field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # 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", | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										32
									
								
								games/migrations/0012_alter_session_duration_calculated.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								games/migrations/0012_alter_session_duration_calculated.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | ||||
| # Generated by Django 5.1.7 on 2025-03-25 20:30 | ||||
|  | ||||
| import django.db.models.expressions | ||||
| import django.db.models.functions.comparison | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("games", "0011_purchase_price_per_game"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name="session", | ||||
|             name="duration_calculated", | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="session", | ||||
|             name="duration_calculated", | ||||
|             field=models.GeneratedField( | ||||
|                 db_persist=True, | ||||
|                 expression=django.db.models.functions.comparison.Coalesce( | ||||
|                     django.db.models.expressions.CombinedExpression( | ||||
|                         models.F("timestamp_end"), "-", models.F("timestamp_start") | ||||
|                     ), | ||||
|                     0, | ||||
|                 ), | ||||
|                 output_field=models.DurationField(), | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,23 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										35
									
								
								games/migrations/0013_game_playtime.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								games/migrations/0013_game_playtime.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| # Generated by Django 5.1.7 on 2025-03-25 20:33 | ||||
|  | ||||
| import datetime | ||||
|  | ||||
| from django.db import migrations, models | ||||
| from django.db.models import F, Sum | ||||
|  | ||||
|  | ||||
| def calculate_game_playtime(apps, schema_editor): | ||||
|     Game = apps.get_model("games", "Game") | ||||
|     games = Game.objects.all() | ||||
|     for game in games: | ||||
|         total_playtime = game.sessions.aggregate( | ||||
|             total_playtime=Sum(F("duration_total")) | ||||
|         )["total_playtime"] | ||||
|         if total_playtime: | ||||
|             game.playtime = total_playtime | ||||
|             game.save(update_fields=["playtime"]) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("games", "0012_alter_session_duration_calculated"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="game", | ||||
|             name="playtime", | ||||
|             field=models.DurationField( | ||||
|                 blank=True, default=datetime.timedelta(0), editable=False | ||||
|             ), | ||||
|         ), | ||||
|         migrations.RunPython(calculate_game_playtime), | ||||
|     ] | ||||
| @ -1,31 +0,0 @@ | ||||
| # 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, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,52 +0,0 @@ | ||||
| # 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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										19
									
								
								games/migrations/0014_session_duration_total.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								games/migrations/0014_session_duration_total.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| # Generated by Django 5.1.7 on 2025-03-25 20:46 | ||||
|  | ||||
| import django.db.models.expressions | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0013_game_playtime'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='session', | ||||
|             name='duration_total', | ||||
|             field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,23 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,51 +0,0 @@ | ||||
| # 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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,141 +0,0 @@ | ||||
| # 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", | ||||
|         ), | ||||
|     ] | ||||
| @ -1,41 +0,0 @@ | ||||
| # 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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,34 +0,0 @@ | ||||
| # 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), | ||||
|     ] | ||||
| @ -1,17 +0,0 @@ | ||||
| # 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")}, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,24 +0,0 @@ | ||||
| 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), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # 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", | ||||
|         ), | ||||
|     ] | ||||
| @ -1,21 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,39 +0,0 @@ | ||||
| # 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), | ||||
|     ] | ||||
| @ -1,39 +0,0 @@ | ||||
| # 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), | ||||
|     ] | ||||
| @ -1,27 +0,0 @@ | ||||
| # 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, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,25 +0,0 @@ | ||||
| # 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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,26 +0,0 @@ | ||||
| # 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), | ||||
|     ] | ||||
| @ -1,26 +0,0 @@ | ||||
| # 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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,44 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,52 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,17 +0,0 @@ | ||||
| # 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")}, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,23 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,25 +0,0 @@ | ||||
| # 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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,19 +0,0 @@ | ||||
| # 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'), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,26 +0,0 @@ | ||||
| # 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), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,33 +0,0 @@ | ||||
| # 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), | ||||
|     ] | ||||
| @ -1,36 +0,0 @@ | ||||
| # 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')}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										355
									
								
								games/models.py
									
									
									
									
									
								
							
							
						
						
									
										355
									
								
								games/models.py
									
									
									
									
									
								
							| @ -1,20 +1,63 @@ | ||||
| import logging | ||||
| from datetime import timedelta | ||||
|  | ||||
| import requests | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.db import models | ||||
| from django.db.models import F, Sum | ||||
| from django.template.defaultfilters import slugify | ||||
| from django.db.models.expressions import RawSQL | ||||
| from django.db.models.fields.generated import GeneratedField | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.template.defaultfilters import floatformat, pluralize, slugify | ||||
| from django.utils import timezone | ||||
|  | ||||
| from common.time import format_duration | ||||
|  | ||||
| logger = logging.getLogger("games") | ||||
|  | ||||
|  | ||||
| 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) | ||||
|     sort_name = models.CharField(max_length=255, blank=True, default="") | ||||
|     year_released = models.IntegerField(null=True, blank=True, default=None) | ||||
|     wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) | ||||
|     original_year_released = models.IntegerField(null=True, blank=True, default=None) | ||||
|     wikidata = models.CharField(max_length=50, blank=True, default="") | ||||
|     platform = models.ForeignKey( | ||||
|         "Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None | ||||
|     ) | ||||
|  | ||||
|     playtime = models.DurationField(blank=True, editable=False, default=timedelta(0)) | ||||
|  | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     class Status(models.TextChoices): | ||||
|         UNPLAYED = ( | ||||
|             "u", | ||||
|             "Unplayed", | ||||
|         ) | ||||
|         PLAYED = ( | ||||
|             "p", | ||||
|             "Played", | ||||
|         ) | ||||
|         FINISHED = ( | ||||
|             "f", | ||||
|             "Finished", | ||||
|         ) | ||||
|         RETIRED = ( | ||||
|             "r", | ||||
|             "Retired", | ||||
|         ) | ||||
|         ABANDONED = ( | ||||
|             "a", | ||||
|             "Abandoned", | ||||
|         ) | ||||
|  | ||||
|     status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED) | ||||
|     mastered = models.BooleanField(default=False) | ||||
|  | ||||
|     session_average: float | int | timedelta | None | ||||
|     session_count: int | None | ||||
| @ -22,10 +65,39 @@ class Game(models.Model): | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
|     def finished(self): | ||||
|         return self.status == self.Status.FINISHED | ||||
|  | ||||
|     def abandoned(self): | ||||
|         return self.status == self.Status.ABANDONED | ||||
|  | ||||
|     def retired(self): | ||||
|         return self.status == self.Status.RETIRED | ||||
|  | ||||
|     def played(self): | ||||
|         return self.status == self.Status.PLAYED | ||||
|  | ||||
|     def unplayed(self): | ||||
|         return self.status == self.Status.UNPLAYED | ||||
|  | ||||
|     def playtime_formatted(self): | ||||
|         return format_duration(self.playtime, "%2.1H") | ||||
|  | ||||
|     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) | ||||
|     group = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||
|     group = models.CharField(max_length=255, blank=True, default="") | ||||
|     icon = models.SlugField(blank=True) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
| @ -38,35 +110,6 @@ 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) | ||||
| @ -74,9 +117,6 @@ class PurchaseQueryset(models.QuerySet): | ||||
|     def not_refunded(self): | ||||
|         return self.filter(date_refunded__isnull=True) | ||||
|  | ||||
|     def finished(self): | ||||
|         return self.filter(date_finished__isnull=False) | ||||
|  | ||||
|     def games_only(self): | ||||
|         return self.filter(type=Purchase.GAME) | ||||
|  | ||||
| @ -113,54 +153,87 @@ class Purchase(models.Model): | ||||
|  | ||||
|     objects = PurchaseQueryset().as_manager() | ||||
|  | ||||
|     edition = models.ForeignKey(Edition, on_delete=models.CASCADE) | ||||
|     games = models.ManyToManyField(Game, related_name="purchases") | ||||
|  | ||||
|     platform = models.ForeignKey( | ||||
|         Platform, on_delete=models.CASCADE, default=None, null=True, blank=True | ||||
|     ) | ||||
|     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) | ||||
|     date_purchased = models.DateField(verbose_name="Purchased") | ||||
|     date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded") | ||||
|     infinite = models.BooleanField(default=False) | ||||
|     price = models.FloatField(default=0) | ||||
|     price_currency = models.CharField(max_length=3, default="USD") | ||||
|     converted_price = models.FloatField(null=True) | ||||
|     converted_currency = models.CharField(max_length=3, null=True) | ||||
|     converted_currency = models.CharField(max_length=3, blank=True, default="") | ||||
|     price_per_game = GeneratedField( | ||||
|         expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"), | ||||
|         output_field=models.FloatField(), | ||||
|         db_persist=True, | ||||
|         editable=False, | ||||
|     ) | ||||
|     num_purchases = models.IntegerField(default=0) | ||||
|     ownership_type = models.CharField( | ||||
|         max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL | ||||
|     ) | ||||
|     type = models.CharField(max_length=255, choices=TYPES, default=GAME) | ||||
|     name = models.CharField(max_length=255, default="", null=True, blank=True) | ||||
|     name = models.CharField(max_length=255, blank=True, default="") | ||||
|     related_purchase = models.ForeignKey( | ||||
|         "self", | ||||
|         on_delete=models.SET_NULL, | ||||
|         default=None, | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         related_name="related_purchases", | ||||
|     ) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     @property | ||||
|     def standardized_price(self): | ||||
|         return ( | ||||
|             f"{floatformat(self.converted_price, 0)} {self.converted_currency}" | ||||
|             if self.converted_price | ||||
|             else None | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def has_one_item(self): | ||||
|         return self.games.count() == 1 | ||||
|  | ||||
|     @property | ||||
|     def standardized_name(self): | ||||
|         return self.name or self.first_game.name | ||||
|  | ||||
|     @property | ||||
|     def first_game(self): | ||||
|         return self.games.first() | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.standardized_name | ||||
|  | ||||
|     @property | ||||
|     def full_name(self): | ||||
|         additional_info = [ | ||||
|             self.get_type_display() if self.type != Purchase.GAME else "", | ||||
|             ( | ||||
|                 f"{self.edition.platform} version on {self.platform}" | ||||
|                 if self.platform != self.edition.platform | ||||
|                 else self.platform | ||||
|             ), | ||||
|             self.edition.year_released, | ||||
|             self.get_ownership_type_display(), | ||||
|             str(item) | ||||
|             for item in [ | ||||
|                 f"{self.num_purchases} game{pluralize(self.num_purchases)}", | ||||
|                 self.date_purchased, | ||||
|                 self.standardized_price, | ||||
|             ] | ||||
|             if item | ||||
|         ] | ||||
|         return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})" | ||||
|         return f"{self.standardized_name} ({', '.join(additional_info)})" | ||||
|  | ||||
|     def is_game(self): | ||||
|         return self.type == self.GAME | ||||
|  | ||||
|     def price_or_currency_differ_from(self, purchase_to_compare): | ||||
|         return ( | ||||
|             self.price != purchase_to_compare.price | ||||
|             or self.price_currency != purchase_to_compare.price_currency | ||||
|         ) | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         if self.type == Purchase.GAME: | ||||
|             self.name = "" | ||||
|         elif self.type != Purchase.GAME and not self.related_purchase: | ||||
|         if self.type != Purchase.GAME and not self.related_purchase: | ||||
|             raise ValidationError( | ||||
|                 f"{self.get_type_display()} must have a related purchase." | ||||
|             ) | ||||
| @ -168,12 +241,15 @@ class Purchase(models.Model): | ||||
|             # Retrieve the existing instance from the database | ||||
|             existing_purchase = Purchase.objects.get(pk=self.pk) | ||||
|             # If price has changed, reset converted fields | ||||
|             if ( | ||||
|                 existing_purchase.price != self.price | ||||
|                 or existing_purchase.price_currency != self.price_currency | ||||
|             ): | ||||
|                 self.converted_price = None | ||||
|                 self.converted_currency = None | ||||
|             if existing_purchase.price_or_currency_differ_from(self): | ||||
|                 from games.tasks import currency_to | ||||
|  | ||||
|                 exchange_rate = get_or_create_rate( | ||||
|                     self.price_currency, currency_to, self.date_purchased.year | ||||
|                 ) | ||||
|                 if exchange_rate: | ||||
|                     self.converted_price = floatformat(self.price * exchange_rate, 0) | ||||
|                     self.converted_currency = currency_to | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| @ -205,11 +281,30 @@ class Session(models.Model): | ||||
|     class Meta: | ||||
|         get_latest_by = "timestamp_start" | ||||
|  | ||||
|     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)) | ||||
|     duration_calculated = models.DurationField(blank=True, null=True) | ||||
|     game = models.ForeignKey( | ||||
|         Game, | ||||
|         on_delete=models.CASCADE, | ||||
|         null=True, | ||||
|         default=None, | ||||
|         related_name="sessions", | ||||
|     ) | ||||
|     timestamp_start = models.DateTimeField(verbose_name="Start") | ||||
|     timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End") | ||||
|     duration_manual = models.DurationField( | ||||
|         blank=True, null=True, default=timedelta(0), verbose_name="Manual duration" | ||||
|     ) | ||||
|     duration_calculated = GeneratedField( | ||||
|         expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0), | ||||
|         output_field=models.DurationField(), | ||||
|         db_persist=True, | ||||
|         editable=False, | ||||
|     ) | ||||
|     duration_total = GeneratedField( | ||||
|         expression=F("duration_calculated") + F("duration_manual"), | ||||
|         output_field=models.DurationField(), | ||||
|         db_persist=True, | ||||
|         editable=False, | ||||
|     ) | ||||
|     device = models.ForeignKey( | ||||
|         "Device", | ||||
|         on_delete=models.SET_DEFAULT, | ||||
| @ -217,15 +312,17 @@ class Session(models.Model): | ||||
|         blank=True, | ||||
|         default=None, | ||||
|     ) | ||||
|     note = models.TextField(blank=True, null=True) | ||||
|     note = models.TextField(blank=True, default="") | ||||
|     emulated = models.BooleanField(default=False) | ||||
|  | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     modified_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     objects = SessionQuerySet.as_manager() | ||||
|  | ||||
|     def __str__(self): | ||||
|         mark = ", manual" if self.is_manual() else "" | ||||
|         return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" | ||||
|         mark = "*" if self.is_manual() else "" | ||||
|         return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" | ||||
|  | ||||
|     def finish_now(self): | ||||
|         self.timestamp_end = timezone.now() | ||||
| @ -233,32 +330,18 @@ class Session(models.Model): | ||||
|     def start_now(): | ||||
|         self.timestamp_start = timezone.now() | ||||
|  | ||||
|     def duration_seconds(self) -> timedelta: | ||||
|         manual = timedelta(0) | ||||
|         calculated = timedelta(0) | ||||
|         if self.is_manual() and isinstance(self.duration_manual, timedelta): | ||||
|             manual = self.duration_manual | ||||
|         if self.timestamp_end != None and self.timestamp_start != None: | ||||
|             calculated = self.timestamp_end - self.timestamp_start | ||||
|         return timedelta(seconds=(manual + calculated).total_seconds()) | ||||
|  | ||||
|     def duration_formatted(self) -> str: | ||||
|         result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") | ||||
|         result = format_duration(self.duration_total, "%02.1H") | ||||
|         return result | ||||
|  | ||||
|     def duration_formatted_with_mark(self) -> str: | ||||
|         mark = "*" if self.is_manual() else "" | ||||
|         return f"{self.duration_formatted()}{mark}" | ||||
|  | ||||
|     def is_manual(self) -> bool: | ||||
|         return not self.duration_manual == timedelta(0) | ||||
|  | ||||
|     @property | ||||
|     def duration_sum(self) -> str: | ||||
|         return Session.objects.all().total_duration_formatted() | ||||
|  | ||||
|     def save(self, *args, **kwargs) -> None: | ||||
|         if self.timestamp_start != None and self.timestamp_end != None: | ||||
|             self.duration_calculated = self.timestamp_end - self.timestamp_start | ||||
|         else: | ||||
|             self.duration_calculated = timedelta(0) | ||||
|  | ||||
|         if not isinstance(self.duration_manual, timedelta): | ||||
|             self.duration_manual = timedelta(0) | ||||
|  | ||||
| @ -304,3 +387,97 @@ class ExchangeRate(models.Model): | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})" | ||||
|  | ||||
|  | ||||
| def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None: | ||||
|     exchange_rate = None | ||||
|     result = ExchangeRate.objects.filter( | ||||
|         currency_from=currency_from, currency_to=currency_to, year=year | ||||
|     ) | ||||
|     if result: | ||||
|         exchange_rate = result[0].rate | ||||
|     else: | ||||
|         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" | ||||
|             ) | ||||
|             response.raise_for_status() | ||||
|             data = response.json() | ||||
|             currency_from_data = data.get(currency_from.lower()) | ||||
|             rate = currency_from_data.get(currency_to.lower()) | ||||
|  | ||||
|             if rate: | ||||
|                 logger.info(f"[convert_prices]: Got {rate}, saving...") | ||||
|                 exchange_rate = ExchangeRate.objects.create( | ||||
|                     currency_from=currency_from, | ||||
|                     currency_to=currency_to, | ||||
|                     year=year, | ||||
|                     rate=floatformat(rate, 2), | ||||
|                 ) | ||||
|                 exchange_rate = exchange_rate.rate | ||||
|             else: | ||||
|                 logger.info("[convert_prices]: Could not get an exchange rate.") | ||||
|         except requests.RequestException as e: | ||||
|             logger.info( | ||||
|                 f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" | ||||
|             ) | ||||
|     return exchange_rate | ||||
|  | ||||
|  | ||||
| class PlayEvent(models.Model): | ||||
|     game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE) | ||||
|     started = models.DateField(null=True, blank=True) | ||||
|     ended = models.DateField(null=True, blank=True) | ||||
|     days_to_finish = GeneratedField( | ||||
|         # special cases: | ||||
|         # missing ended, started, or both = 0 | ||||
|         # same day = 1 day to finish | ||||
|         expression=RawSQL( | ||||
|             """ | ||||
|             COALESCE( | ||||
|                 CASE  | ||||
|                     WHEN date(ended) = date(started) THEN 1 | ||||
|                     ELSE julianday(ended) - julianday(started) | ||||
|                 END, 0 | ||||
|             ) | ||||
|             """, | ||||
|             [], | ||||
|         ), | ||||
|         output_field=models.IntegerField(), | ||||
|         db_persist=True, | ||||
|         editable=False, | ||||
|         blank=True, | ||||
|     ) | ||||
|     note = models.CharField(max_length=255, blank=True, default="") | ||||
|  | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|  | ||||
| # class PlayMarker(models.Model): | ||||
| #     game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE) | ||||
| #     played_since = models.DurationField() | ||||
| #     played_total = models.DurationField() | ||||
| #     note = models.CharField(max_length=255) | ||||
|  | ||||
|  | ||||
| class GameStatusChange(models.Model): | ||||
|     """ | ||||
|     Tracks changes to the status of a Game. | ||||
|     """ | ||||
|  | ||||
|     game = models.ForeignKey( | ||||
|         Game, on_delete=models.CASCADE, related_name="status_changes" | ||||
|     ) | ||||
|     old_status = models.CharField( | ||||
|         max_length=1, choices=Game.Status.choices, blank=True, null=True | ||||
|     ) | ||||
|     new_status = models.CharField(max_length=1, choices=Game.Status.choices) | ||||
|     timestamp = models.DateTimeField(null=True) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}" | ||||
|  | ||||
|     class Meta: | ||||
|         ordering = ["-timestamp"] | ||||
|  | ||||
							
								
								
									
										58
									
								
								games/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								games/signals.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | ||||
| import logging | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.db.models import F, Sum | ||||
| from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save | ||||
| from django.dispatch import receiver | ||||
| from django.utils.timezone import now | ||||
|  | ||||
| from games.models import Game, GameStatusChange, Purchase, Session | ||||
|  | ||||
| logger = logging.getLogger("games") | ||||
|  | ||||
|  | ||||
| @receiver(m2m_changed, sender=Purchase.games.through) | ||||
| def update_num_purchases(sender, instance, **kwargs): | ||||
|     instance.num_purchases = instance.games.count() | ||||
|     instance.updated_at = now() | ||||
|     instance.save(update_fields=["num_purchases"]) | ||||
|  | ||||
|  | ||||
| @receiver([post_save, post_delete], sender=Session) | ||||
| def update_game_playtime(sender, instance, **kwargs): | ||||
|     game = instance.game | ||||
|     total_playtime = game.sessions.aggregate( | ||||
|         total_playtime=Sum(F("duration_calculated") + F("duration_manual")) | ||||
|     )["total_playtime"] | ||||
|     game.playtime = total_playtime if total_playtime else timedelta(0) | ||||
|     game.save(update_fields=["playtime"]) | ||||
|  | ||||
|  | ||||
| @receiver(pre_save, sender=Game) | ||||
| def game_status_changed(sender, instance, **kwargs): | ||||
|     """ | ||||
|     Signal handler to create a GameStatusChange record whenever a Game's status is updated. | ||||
|     """ | ||||
|     try: | ||||
|         old_instance = sender.objects.get(pk=instance.pk) | ||||
|         old_status = old_instance.status | ||||
|         logger.info("[game_status_changed]: Previous status exists.") | ||||
|     except sender.DoesNotExist: | ||||
|         # Handle the case where the instance was deleted before the signal was sent | ||||
|         logger.info("[game_status_changed]: Previous status does not exist.") | ||||
|         return | ||||
|  | ||||
|     if old_status != instance.status: | ||||
|         logger.info( | ||||
|             "[game_status_changed]: Status changed from {} to {}".format( | ||||
|                 old_status, instance.status | ||||
|             ) | ||||
|         ) | ||||
|         GameStatusChange.objects.create( | ||||
|             game=instance, | ||||
|             old_status=old_status, | ||||
|             new_status=instance.status, | ||||
|             timestamp=now(), | ||||
|         ) | ||||
|     else: | ||||
|         logger.info("[game_status_changed]: Status has not changed") | ||||
| @ -1299,6 +1299,14 @@ input:checked + .toggle-bg { | ||||
|   bottom: 0px; | ||||
| } | ||||
|  | ||||
| .-left-3 { | ||||
|   left: -0.75rem; | ||||
| } | ||||
|  | ||||
| .-left-\[1px\] { | ||||
|   left: -1px; | ||||
| } | ||||
|  | ||||
| .bottom-0 { | ||||
|   bottom: 0px; | ||||
| } | ||||
| @ -1335,10 +1343,18 @@ input:checked + .toggle-bg { | ||||
|   top: 0px; | ||||
| } | ||||
|  | ||||
| .top-2 { | ||||
|   top: 0.5rem; | ||||
| } | ||||
|  | ||||
| .top-3 { | ||||
|   top: 0.75rem; | ||||
| } | ||||
|  | ||||
| .top-\[100\%\] { | ||||
|   top: 100%; | ||||
| } | ||||
|  | ||||
| .z-10 { | ||||
|   z-index: 10; | ||||
| } | ||||
| @ -1415,6 +1431,10 @@ input:checked + .toggle-bg { | ||||
|   margin-inline-end: 0.5rem; | ||||
| } | ||||
|  | ||||
| .ml-3 { | ||||
|   margin-left: 0.75rem; | ||||
| } | ||||
|  | ||||
| .mr-4 { | ||||
|   margin-right: 1rem; | ||||
| } | ||||
| @ -1488,6 +1508,10 @@ input:checked + .toggle-bg { | ||||
|   height: 3rem; | ||||
| } | ||||
|  | ||||
| .h-2 { | ||||
|   height: 0.5rem; | ||||
| } | ||||
|  | ||||
| .h-2\.5 { | ||||
|   height: 0.625rem; | ||||
| } | ||||
| @ -1524,6 +1548,10 @@ input:checked + .toggle-bg { | ||||
|   width: 2.5rem; | ||||
| } | ||||
|  | ||||
| .w-2 { | ||||
|   width: 0.5rem; | ||||
| } | ||||
|  | ||||
| .w-2\.5 { | ||||
|   width: 0.625rem; | ||||
| } | ||||
| @ -1532,6 +1560,10 @@ input:checked + .toggle-bg { | ||||
|   width: 6rem; | ||||
| } | ||||
|  | ||||
| .w-3 { | ||||
|   width: 0.75rem; | ||||
| } | ||||
|  | ||||
| .w-4 { | ||||
|   width: 1rem; | ||||
| } | ||||
| @ -1556,6 +1588,10 @@ input:checked + .toggle-bg { | ||||
|   width: 20rem; | ||||
| } | ||||
|  | ||||
| .w-auto { | ||||
|   width: auto; | ||||
| } | ||||
|  | ||||
| .w-full { | ||||
|   width: 100%; | ||||
| } | ||||
| @ -1576,6 +1612,10 @@ input:checked + .toggle-bg { | ||||
|   max-width: 24rem; | ||||
| } | ||||
|  | ||||
| .max-w-xl { | ||||
|   max-width: 36rem; | ||||
| } | ||||
|  | ||||
| .max-w-xs { | ||||
|   max-width: 20rem; | ||||
| } | ||||
| @ -1652,6 +1692,14 @@ input:checked + .toggle-bg { | ||||
|   resize: both; | ||||
| } | ||||
|  | ||||
| .list-inside { | ||||
|   list-style-position: inside; | ||||
| } | ||||
|  | ||||
| .list-disc { | ||||
|   list-style-type: disc; | ||||
| } | ||||
|  | ||||
| .grid-cols-4 { | ||||
|   grid-template-columns: repeat(4, minmax(0, 1fr)); | ||||
| } | ||||
| @ -1696,6 +1744,10 @@ input:checked + .toggle-bg { | ||||
|   justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .gap-1 { | ||||
|   gap: 0.25rem; | ||||
| } | ||||
|  | ||||
| .gap-2 { | ||||
|   gap: 0.5rem; | ||||
| } | ||||
| @ -1708,6 +1760,10 @@ input:checked + .toggle-bg { | ||||
|   gap: 1.25rem; | ||||
| } | ||||
|  | ||||
| .gap-y-4 { | ||||
|   row-gap: 1rem; | ||||
| } | ||||
|  | ||||
| .-space-x-px > :not([hidden]) ~ :not([hidden]) { | ||||
|   --tw-space-x-reverse: 0; | ||||
|   margin-right: calc(-1px * var(--tw-space-x-reverse)); | ||||
| @ -1779,6 +1835,15 @@ input:checked + .toggle-bg { | ||||
|   border-radius: 0.125rem; | ||||
| } | ||||
|  | ||||
| .rounded-xl { | ||||
|   border-radius: 0.75rem; | ||||
| } | ||||
|  | ||||
| .rounded-b-md { | ||||
|   border-bottom-right-radius: 0.375rem; | ||||
|   border-bottom-left-radius: 0.375rem; | ||||
| } | ||||
|  | ||||
| .rounded-e-lg { | ||||
|   border-start-end-radius: 0.5rem; | ||||
|   border-end-end-radius: 0.5rem; | ||||
| @ -1799,6 +1864,14 @@ input:checked + .toggle-bg { | ||||
|   border-end-start-radius: 0.5rem; | ||||
| } | ||||
|  | ||||
| .rounded-tl-none { | ||||
|   border-top-left-radius: 0px; | ||||
| } | ||||
|  | ||||
| .rounded-tr-md { | ||||
|   border-top-right-radius: 0.375rem; | ||||
| } | ||||
|  | ||||
| .border { | ||||
|   border-width: 1px; | ||||
| } | ||||
| @ -1807,6 +1880,18 @@ input:checked + .toggle-bg { | ||||
|   border-width: 0px; | ||||
| } | ||||
|  | ||||
| .border-b { | ||||
|   border-bottom-width: 1px; | ||||
| } | ||||
|  | ||||
| .border-e { | ||||
|   border-inline-end-width: 1px; | ||||
| } | ||||
|  | ||||
| .border-t { | ||||
|   border-top-width: 1px; | ||||
| } | ||||
|  | ||||
| .border-blue-600 { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(28 100 242 / var(--tw-border-opacity)); | ||||
| @ -1872,15 +1957,29 @@ input:checked + .toggle-bg { | ||||
|   background-color: rgb(249 250 251 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-gray-500 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(107 114 128 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-gray-800 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(31 41 55 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-gray-800\/20 { | ||||
|   background-color: rgb(31 41 55 / 0.2); | ||||
| } | ||||
|  | ||||
| .bg-gray-900\/50 { | ||||
|   background-color: rgb(17 24 39 / 0.5); | ||||
| } | ||||
|  | ||||
| .bg-green-500 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(14 159 110 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-green-600 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(5 122 85 / var(--tw-bg-opacity)); | ||||
| @ -1891,6 +1990,21 @@ input:checked + .toggle-bg { | ||||
|   background-color: rgb(4 108 78 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-orange-400 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(255 138 76 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-purple-500 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(144 97 249 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-red-500 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(240 82 82 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-red-700 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(200 30 30 / var(--tw-bg-opacity)); | ||||
| @ -2022,6 +2136,10 @@ input:checked + .toggle-bg { | ||||
|   vertical-align: top; | ||||
| } | ||||
|  | ||||
| .align-middle { | ||||
|   vertical-align: middle; | ||||
| } | ||||
|  | ||||
| .font-mono { | ||||
|   font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | ||||
| } | ||||
| @ -2169,6 +2287,11 @@ input:checked + .toggle-bg { | ||||
|   color: rgb(203 213 225 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .text-slate-400 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(148 163 184 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .text-slate-500 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(100 116 139 / var(--tw-text-opacity)); | ||||
| @ -2192,6 +2315,10 @@ input:checked + .toggle-bg { | ||||
|   text-decoration-color: #64748b; | ||||
| } | ||||
|  | ||||
| .decoration-dotted { | ||||
|   text-decoration-style: dotted; | ||||
| } | ||||
|  | ||||
| .opacity-0 { | ||||
|   opacity: 0; | ||||
| } | ||||
| @ -2237,6 +2364,12 @@ input:checked + .toggle-bg { | ||||
|   filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); | ||||
| } | ||||
|  | ||||
| .backdrop-blur-lg { | ||||
|   --tw-backdrop-blur: blur(16px); | ||||
|   -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); | ||||
|   backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); | ||||
| } | ||||
|  | ||||
| .transition { | ||||
|   transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; | ||||
|   transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; | ||||
| @ -2337,10 +2470,9 @@ input:checked + .toggle-bg { | ||||
|   transition: all 0.2s ease-out; | ||||
| } */ | ||||
|  | ||||
| form label:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(148 163 184 / var(--tw-text-opacity)); | ||||
| } | ||||
| /* form label { | ||||
|   @apply dark:text-slate-400; | ||||
| } */ | ||||
|  | ||||
| .responsive-table { | ||||
|   margin-left: auto; | ||||
| @ -2379,25 +2511,25 @@ form label:is(.dark *) { | ||||
|   border-left-color: rgb(100 116 139 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| form input:is(.dark *), | ||||
| select:is(.dark *), | ||||
| textarea:is(.dark *) { | ||||
|   border-width: 1px; | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(15 23 42 / var(--tw-border-opacity)); | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(100 116 139 / var(--tw-bg-opacity)); | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(241 245 249 / var(--tw-text-opacity)); | ||||
| /* form input, | ||||
| select, | ||||
| textarea { | ||||
|   @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; | ||||
| } */ | ||||
|  | ||||
| form input:disabled, | ||||
| select:disabled, | ||||
| textarea:disabled { | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| form input:disabled:is(.dark *), | ||||
| select:disabled:is(.dark *), | ||||
| textarea:disabled:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(51 65 85 / var(--tw-bg-opacity)); | ||||
|   background-color: rgb(30 41 59 / var(--tw-bg-opacity)); | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(148 163 184 / var(--tw-text-opacity)); | ||||
|   color: rgb(100 116 139 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .errorlist { | ||||
| @ -2413,21 +2545,21 @@ textarea:disabled:is(.dark *) { | ||||
|   color: rgb(226 232 240 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| @media screen and (min-width: 768px) { | ||||
| /* @media screen and (min-width: 768px) { | ||||
|   form input, | ||||
|   select, | ||||
|   textarea { | ||||
|     width: 300px; | ||||
|   } | ||||
| } | ||||
| } */ | ||||
|  | ||||
| @media screen and (max-width: 768px) { | ||||
| /* @media screen and (max-width: 768px) { | ||||
|   form input, | ||||
|   select, | ||||
|   textarea { | ||||
|     width: 150px; | ||||
|   } | ||||
| } | ||||
| } */ | ||||
|  | ||||
| #button-container button { | ||||
|   margin-left: 0.25rem; | ||||
| @ -2536,6 +2668,47 @@ textarea:disabled:is(.dark *) { | ||||
|   }   | ||||
| } */ | ||||
|  | ||||
| label:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(100 116 139 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| [type="text"]:is(.dark *), [type="password"]:is(.dark *), [type="datetime-local"]:is(.dark *), [type="datetime"]:is(.dark *), [type="date"]:is(.dark *), [type="number"]:is(.dark *), select:is(.dark *), textarea:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(71 85 105 / var(--tw-bg-opacity)); | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(203 213 225 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| [type="submit"] { | ||||
|   padding-left: 1rem; | ||||
|   padding-right: 1rem; | ||||
|   padding-top: 0.5rem; | ||||
|   padding-bottom: 0.5rem; | ||||
|   font-weight: 700; | ||||
| } | ||||
|  | ||||
| [type="submit"]:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(28 100 242 / var(--tw-bg-opacity)); | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(255 255 255 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| form div label:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(255 255 255 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| form div { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| div [type="submit"] { | ||||
|   margin-top: 0.75rem; | ||||
| } | ||||
|  | ||||
| .odd\:bg-white:nth-child(odd) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(255 255 255 / var(--tw-bg-opacity)); | ||||
| @ -3117,6 +3290,10 @@ 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)); | ||||
| @ -3260,3 +3437,13 @@ 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_edition", | ||||
|     source: "#id_games", | ||||
|     source_value: "dataset.platform", | ||||
|     target: "#id_platform", | ||||
|     target_value: "value", | ||||
| @ -21,11 +21,6 @@ function setupElementHandlers() { | ||||
|     "#id_name", | ||||
|     "#id_related_purchase", | ||||
|   ]); | ||||
|   disableElementsWhenValueNotEqual( | ||||
|     "#id_type", | ||||
|     ["game", "dlc"], | ||||
|     ["#id_date_finished"] | ||||
|   ); | ||||
| } | ||||
|  | ||||
| document.addEventListener("DOMContentLoaded", setupElementHandlers); | ||||
| @ -36,8 +31,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_edition") { | ||||
|     var idEditionValue = document.getElementById("id_edition").value; | ||||
|   if (event.target.id === "id_games") { | ||||
|     var idEditionValue = document.getElementById("id_games").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_game", "#id_edition", "#id_platform"]; | ||||
| const toggleableFields = ["#id_games", "#id_platform"]; | ||||
|  | ||||
| toggleableFields.map((selector) => { | ||||
|   addToggleButton(document.querySelector(selector)); | ||||
|  | ||||
| @ -1,4 +1,11 @@ | ||||
| import requests | ||||
| from django.db.models import ExpressionWrapper, F, FloatField, Q | ||||
| from django.template.defaultfilters import floatformat | ||||
| from django.utils.timezone import now | ||||
| from django_q.models import Task | ||||
| import logging | ||||
|  | ||||
| logger = logging.getLogger("games") | ||||
|  | ||||
| from games.models import ExchangeRate, Purchase | ||||
|  | ||||
| @ -8,8 +15,8 @@ currency_to = currency_to.upper() | ||||
|  | ||||
|  | ||||
| def save_converted_info(purchase, converted_price, converted_currency): | ||||
|     print( | ||||
|         f"Changing converted price of {purchase} to {converted_price} {converted_currency} " | ||||
|     logger.info( | ||||
|         f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})" | ||||
|     ) | ||||
|     purchase.converted_price = converted_price | ||||
|     purchase.converted_currency = converted_currency | ||||
| @ -18,8 +25,10 @@ def save_converted_info(purchase, converted_price, converted_currency): | ||||
|  | ||||
| def convert_prices(): | ||||
|     purchases = Purchase.objects.filter( | ||||
|         converted_price__isnull=True, converted_currency__isnull=True | ||||
|         converted_price__isnull=True, converted_currency="" | ||||
|     ) | ||||
|     if purchases.count() == 0: | ||||
|         logger.info("[convert_prices]: No prices to convert.") | ||||
|  | ||||
|     for purchase in purchases: | ||||
|         if purchase.price_currency.upper() == currency_to or purchase.price == 0: | ||||
| @ -27,31 +36,59 @@ def convert_prices(): | ||||
|             continue | ||||
|         year = purchase.date_purchased.year | ||||
|         currency_from = purchase.price_currency.upper() | ||||
|  | ||||
|         exchange_rate = ExchangeRate.objects.filter( | ||||
|             currency_from=currency_from, currency_to=currency_to, year=year | ||||
|         ).first() | ||||
|  | ||||
|         logger.info(f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}") | ||||
|         if not exchange_rate: | ||||
|             logger.info( | ||||
|                 f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..." | ||||
|             ) | ||||
|             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}.json" | ||||
|                     f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json" | ||||
|                 ) | ||||
|                 response.raise_for_status() | ||||
|                 data = response.json() | ||||
|                 rate = data[currency_from].get(currency_to) | ||||
|                 currency_from_data = data.get(currency_from.lower()) | ||||
|                 rate = currency_from_data.get(currency_to.lower()) | ||||
|  | ||||
|                 if rate: | ||||
|                     logger.info(f"[convert_prices]: Got {rate}, saving...") | ||||
|                     exchange_rate = ExchangeRate.objects.create( | ||||
|                         currency_from=currency_from, | ||||
|                         currency_to=currency_to, | ||||
|                         year=year, | ||||
|                         rate=rate, | ||||
|                         rate=floatformat(rate, 2), | ||||
|                     ) | ||||
|                 else: | ||||
|                     logger.info("[convert_prices]: Could not get an exchange rate.") | ||||
|             except requests.RequestException as e: | ||||
|                 print( | ||||
|                     f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" | ||||
|                 logger.info( | ||||
|                     f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" | ||||
|                 ) | ||||
|         if exchange_rate: | ||||
|             save_converted_info( | ||||
|                 purchase, purchase.price * exchange_rate.rate, currency_to | ||||
|                 purchase, | ||||
|                 floatformat(purchase.price * exchange_rate.rate, 0), | ||||
|                 currency_to, | ||||
|             ) | ||||
|  | ||||
|  | ||||
| def calculate_price_per_game(): | ||||
|     try: | ||||
|         last_task = Task.objects.filter(group="Update price per game").first() | ||||
|         last_run = last_task.started | ||||
|     except Task.DoesNotExist or AttributeError: | ||||
|         last_run = now() | ||||
|     purchases = Purchase.objects.filter(converted_price__isnull=False).filter( | ||||
|         Q(updated_at__gte=last_run) | Q(price_per_game__isnull=True) | ||||
|     ) | ||||
|     logger.info(f"[calculate_price_per_game]: Updating {purchases.count()} purchases.") | ||||
|     purchases.update( | ||||
|         price_per_game=ExpressionWrapper( | ||||
|             F("converted_price") / F("num_purchases"), output_field=FloatField() | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
| @ -1,12 +0,0 @@ | ||||
| <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> | ||||
| @ -1,12 +1,7 @@ | ||||
| <c-layouts.add> | ||||
| <c-slot name="additional_row"> | ||||
| <tr> | ||||
|     <td></td> | ||||
|     <td> | ||||
|         <input type="submit" | ||||
|                name="submit_and_redirect" | ||||
|                value="Submit & Create Edition" /> | ||||
|     </td> | ||||
| </tr> | ||||
|     <input type="submit" | ||||
|                         name="submit_and_redirect" | ||||
|                         value="Submit & Create Purchase" /> | ||||
| </c-slot> | ||||
| </c-layouts.add> | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| <c-vars color="blue" size="base" /> | ||||
| <button type="button" | ||||
| <c-vars color="blue" size="base" type="button" /> | ||||
| <button type="{{ type }}"  | ||||
|         title="{{ title }}" | ||||
|         class=" {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} "> | ||||
|         class="{{ class }} {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} "> | ||||
|     {{ slot }} | ||||
| </button> | ||||
|  | ||||
							
								
								
									
										16
									
								
								games/templates/cotton/gamestatus.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								games/templates/cotton/gamestatus.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| <span class="relative ml-3 {{class}}"> | ||||
|     <span class="rounded-xl w-2 h-2 absolute -left-3 top-2 | ||||
|     {% if status == "u" %} | ||||
|     bg-gray-500 | ||||
|     {% elif status == "p" %} | ||||
|     bg-orange-400 | ||||
|     {% elif status == "f" %} | ||||
|     bg-green-500 | ||||
|     {% elif status == "a" %} | ||||
|     bg-red-500 | ||||
|     {% elif status == "r" %} | ||||
|     bg-purple-500 | ||||
|     {% endif %} | ||||
|     "> </span> | ||||
|     {{ slot }} | ||||
| </span> | ||||
							
								
								
									
										6
									
								
								games/templates/cotton/icon/emulated.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								games/templates/cotton/icon/emulated.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| <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> | ||||
| @ -3,19 +3,18 @@ | ||||
| {% if form_content %} | ||||
|     {{ form_content }} | ||||
| {% else %} | ||||
|     <form method="post" enctype="multipart/form-data"> | ||||
|         <table class="mx-auto"> | ||||
| <div class="max-width-container"> | ||||
|     <div class="form-container max-w-xl mx-auto"> | ||||
|         <form method="post" enctype="multipart/form-data"> | ||||
|             {% csrf_token %} | ||||
|             {{ form.as_table }} | ||||
|             <tr> | ||||
|                 <td></td> | ||||
|                 <td> | ||||
|                     <input type="submit" value="Submit" /> | ||||
|                 </td> | ||||
|             </tr> | ||||
|             {{ additional_row }} | ||||
|         </table> | ||||
|     </form> | ||||
|             {{ form.as_div }} | ||||
|             <div><input type="submit" value="Submit" /></div> | ||||
|             <div class="submit-button-container"> | ||||
|                 {{ additional_row }} | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
| {% endif %} | ||||
| <c-slot name="scripts"> | ||||
| {% if script_name %} | ||||
|  | ||||
| @ -12,6 +12,10 @@ | ||||
|         {% django_htmx_script %} | ||||
|         <link rel="stylesheet" href="{% static 'base.css' %}" /> | ||||
|         <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script> | ||||
|         {% comment %} <script src="//unpkg.com/alpinejs" defer></script> | ||||
|         <script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %} | ||||
|         <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script> | ||||
|         <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> | ||||
|         <script> | ||||
|         // On page load or when changing themes, best to add inline in `head` to avoid FOUC | ||||
|             if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| <span data-popover-target={{ id }} class="{{ class }}">{{ wrapped_content|default:slot }}</span> | ||||
| <span data-popover-target={{ id }} class="{{ wrapped_classes }}">{{ 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> | ||||
|  | ||||
							
								
								
									
										1
									
								
								games/templates/cotton/price_converted.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								games/templates/cotton/price_converted.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <span title="Price is a result of conversion and rounding." class="decoration-dotted underline">{{ slot }}</span> | ||||
| @ -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"> | ||||
|             <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"> | ||||
|                 <tr> | ||||
|                     {% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %} | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody class="dark:divide-y"> | ||||
|             <tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden"> | ||||
|                 {% 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-column md:flex-row justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg" | ||||
|         <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" | ||||
|              aria-label="Table navigation"> | ||||
|             <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> | ||||
|             <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> | ||||
|             <ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8"> | ||||
|                 <li> | ||||
|                     {% if page_obj.has_previous %} | ||||
|  | ||||
							
								
								
									
										16
									
								
								games/templates/gamestatuschange_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								games/templates/gamestatuschange_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| <c-layouts.base> | ||||
|     {% load static %} | ||||
|     <div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center"> | ||||
|         <form method="post" class="dark:text-white"> | ||||
|             {% csrf_token %} | ||||
|             <div> | ||||
|                 <p>Are you sure you want to delete this status change?</p> | ||||
|                 <c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button> | ||||
|                 <a href="{% url 'view_game' object.game.id %}" class=""> | ||||
|                     <c-button color="gray" class="w-full">Cancel</c-button> | ||||
|                 </a> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </c-layouts.base> | ||||
|  | ||||
							
								
								
									
										7
									
								
								games/templates/gamestatuschange_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								games/templates/gamestatuschange_list.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| <c-layouts.base> | ||||
|     {% load static %} | ||||
|     <div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center"> | ||||
|         <c-simple-table :columns=["Test"] :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action /> | ||||
|     </div> | ||||
|     </c-layouts.base> | ||||
|      | ||||
							
								
								
									
										6
									
								
								games/templates/list_playevents.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								games/templates/list_playevents.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| <c-layouts.base> | ||||
| {% load static %} | ||||
| <div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center"> | ||||
|     <c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action /> | ||||
| </div> | ||||
| </c-layouts.base> | ||||
| @ -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.purchase.edition.game.id %}"> | ||||
|                                         {{ session.purchase.edition.name }} | ||||
|                                        href="{% url 'view_game' session.game.id %}"> | ||||
|                                         {{ session.game.name }} | ||||
|                                     </a> | ||||
|                                 </span> | ||||
|                             </td> | ||||
|  | ||||
| @ -25,7 +25,11 @@ | ||||
|             </svg> | ||||
|         </button> | ||||
|         <div class="hidden w-full md:block md:w-auto" id="navbar-dropdown"> | ||||
|             <ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"> | ||||
|             <ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"> | ||||
|                 <li class="text-white flex flex-col items-center text-xs"> | ||||
|                     <span class="flex uppercase gap-1">Today<span class="text-gray-400">·</span>Last 7 days</span> | ||||
|                     <span class="flex items-center gap-1">{{ today_played }}<span class="text-gray-400">·</span>{{ last_7_played }}</span> | ||||
|                 </li> | ||||
|                 <li> | ||||
|                     <a href="#" | ||||
|                        class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" | ||||
| @ -57,10 +61,6 @@ | ||||
|                                 <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> | ||||
| @ -102,14 +102,14 @@ | ||||
|                                 <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> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_playevents' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_purchases' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a> | ||||
|  | ||||
| @ -1,12 +1,17 @@ | ||||
| <c-layouts.base> | ||||
| {% load static %} | ||||
| {% load duration_formatter %} | ||||
| {% partialdef purchase-name %} | ||||
| {% if purchase.type != 'game' %} | ||||
|     <c-gamelink :game_id=purchase.edition.game.id> | ||||
|     {{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }}) | ||||
|     <c-gamelink :game_id=purchase.first_game.id> | ||||
|     {% if purchase.game_name %}{{ purchase.game_name }}{% else %}{{ purchase.name }}{% endif %} ({{ purchase.first_game.name }} {{ purchase.get_type_display }}) | ||||
|     </c-gamelink> | ||||
| {% else %} | ||||
|     <c-gamelink :game_id=purchase.edition.game.id :name=purchase.edition.name /> | ||||
|     {% if purchase.game_name %} | ||||
|         <c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name /> | ||||
|     {% else %} | ||||
|         <c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name /> | ||||
|     {% endif %} | ||||
| {% endif %} | ||||
| {% endpartialdef %} | ||||
| <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> | ||||
| @ -46,7 +51,7 @@ | ||||
|             {% endif %} | ||||
|             <tr> | ||||
|                 <td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td> | ||||
|                 <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td> | ||||
|                 <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_year_games }}</td> | ||||
|             </tr> | ||||
|             {% if all_finished_this_year_count %} | ||||
|                 <tr> | ||||
| @ -100,14 +105,14 @@ | ||||
|             {% endif %} | ||||
|         </tbody> | ||||
|     </table> | ||||
|     {% if month_playtime %} | ||||
|     {% if month_playtimes %} | ||||
|         <h1 class="text-5xl text-center my-6">Playtime per month</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <tbody> | ||||
|                 {% for month in month_playtimes %} | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime | format_duration }}</td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
| @ -142,16 +147,18 @@ | ||||
|             </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 }} ({{ spent_per_game }}/game)</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> | ||||
|             </tr> | ||||
|         </tbody> | ||||
|     </table> | ||||
|     <h1 class="text-5xl text-center my-6">Top games by playtime</h1> | ||||
|     <h1 class="text-5xl text-center my-6">Games by playtime</h1> | ||||
|     <table class="responsive-table"> | ||||
|         <thead> | ||||
|             <tr> | ||||
|                 <th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th> | ||||
|                 <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th> | ||||
|                 <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
| @ -160,7 +167,7 @@ | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                         <c-gamelink :game_id=game.id :name=game.name /> | ||||
|                     </td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.total_playtime | format_duration }}</td> | ||||
|                 </tr> | ||||
|             {% endfor %} | ||||
|         </tbody> | ||||
| @ -170,14 +177,14 @@ | ||||
|         <thead> | ||||
|             <tr> | ||||
|                 <th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th> | ||||
|                 <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th> | ||||
|                 <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|             {% for item in total_playtime_per_platform %} | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.playtime | format_duration }}</td> | ||||
|                 </tr> | ||||
|             {% endfor %} | ||||
|         </tbody> | ||||
| @ -253,7 +260,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 }}</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.date_purchased | date:"d/m/Y" }}</td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
| @ -274,7 +281,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 }}</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.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> <c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover> | ||||
|                 <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> | ||||
|             </div> | ||||
|             <div class="flex gap-4 dark:text-slate-400 mb-3"> | ||||
| @ -16,7 +16,7 @@ | ||||
|                      class="size-6"> | ||||
|                     <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> | ||||
|                 </svg> | ||||
|                 {{ hours_sum }} | ||||
|                 {{ game.playtime_formatted }} | ||||
|                 </c-popover> | ||||
|                 <c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" | ||||
| @ -52,6 +52,68 @@ | ||||
|                 {{ playrange }} | ||||
|                 </c-popover> | ||||
|             </div> | ||||
|             <div class="flex flex-col mb-6 text-slate-400 gap-y-4"> | ||||
|                 <div class="flex gap-2 items-center"> | ||||
|                     <span class="uppercase">Original year</span> | ||||
|                     <span class="text-slate-300">{{ game.original_year_released }}</span> | ||||
|                 </div> | ||||
|                 <div class="flex gap-2 items-center"> | ||||
|                     <span class="uppercase">Status</span> | ||||
|                     <c-gamestatus :status="game.status" class="text-slate-300"> | ||||
|                         {{ game.get_status_display }} | ||||
|                     </c-gamestatus> | ||||
|                     {% if game.mastered %}👑{% endif %}  | ||||
|                 </div> | ||||
|                 <div class="flex gap-2 items-center" | ||||
|                      x-data="{ open: false }" | ||||
|                 > | ||||
|                     <span class="uppercase">Played</span> | ||||
|                     <div class="inline-flex rounded-md shadow-xs" role="group" x-data="{ played: {{ game.playevents.count }} }"> | ||||
|                         <a href="{% url 'add_playevent' %}"> | ||||
|                         <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"> | ||||
|                             <span x-text="played"></span> times | ||||
|                         </button> | ||||
|                         </a> | ||||
|                         <button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-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 align-middle"> | ||||
|                             <svg class="text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|                                 <path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | ||||
|                                 </svg> | ||||
|                             <div | ||||
|                                 class="absolute top-[100%] -left-[1px] w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700" | ||||
|                                 x-show="open" | ||||
|                             > | ||||
|                                 <ul | ||||
|                                     class="" | ||||
|                                 > | ||||
|                                     <li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md"> | ||||
|                                         <a href="{% url 'add_playevent_for_game' game.id %}">Add playthrough...</a> | ||||
|                                     </li> | ||||
|                                     <li | ||||
|                                         x-on:click="createPlayEvent" | ||||
|                                         class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md" | ||||
|                                     > | ||||
|                                         Played times +1 | ||||
|                                     </li> | ||||
|                                     <script> | ||||
|                                        function createPlayEvent() { | ||||
|                                         this.played++; | ||||
|                                         fetch('{% url 'api-1.0.0:create_playevent' %}', { method: 'POST', headers: { 'X-CSRFToken': '{{ csrf_token }}' }, body: '{"game_id": {{ game.id }}}'}) | ||||
|                                        } | ||||
|                                     </script> | ||||
|                                 </ul> | ||||
|                             </div> | ||||
|                         </button> | ||||
|                       </div> | ||||
|                 </div> | ||||
|  | ||||
|  | ||||
|    | ||||
|    | ||||
|                 <div class="flex gap-2 items-center"> | ||||
|                     <span class="uppercase">Platform</span> | ||||
|                     <span class="text-slate-300">{{ game.platform }}</span> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="inline-flex rounded-md shadow-sm mb-3" role="group"> | ||||
|                 <a href="{% url 'edit_game' game.id %}"> | ||||
|                     <button type="button" | ||||
| @ -67,17 +129,39 @@ | ||||
|                 </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> | ||||
|             {% if purchase_count %} | ||||
|             <c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns /> | ||||
|             {% else %} | ||||
|             No purchases yet. | ||||
|             {% endif %} | ||||
|         </div> | ||||
|         <div class="mb-6"> | ||||
|             <c-h1 :badge="session_count">Sessions</c-h1> | ||||
|             {% if session_count %} | ||||
|             <c-simple-table :rows=session_data.rows :columns=session_data.columns :header_action=session_data.header_action :page_obj=session_page_obj :elided_page_range=session_elided_page_range /> | ||||
|             {% else %} | ||||
|             No sessions yet. | ||||
|             {% endif %} | ||||
|         </div> | ||||
|         <!-- list all playevents --> | ||||
|         <div class="mb-6"> | ||||
|             <c-h1 :badge="playevent_count">Play Events</c-h1> | ||||
|             {% if playevent_count %} | ||||
|                 <c-simple-table :rows=playevent_data.rows :columns=playevent_data.columns /> | ||||
|             {% else %} | ||||
|                 No play events yet. | ||||
|             {% endif %} | ||||
|         </div> | ||||
|         <div class="mb-6"> | ||||
|             <c-h1 :badge="statuschange_count">History</c-h1> | ||||
|             <ul class="list-disc list-inside"> | ||||
|                 {% for change in statuschanges %} | ||||
|                 <li class="text-slate-500"> | ||||
|                     {% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status" class="text-white">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status" class="text-white">{{ change.get_new_status_display }}</c-gamestatus> (<a href="{% url 'edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'delete_statuschange' change.id %}">Delete</a>)</li> | ||||
|                 {% endfor %} | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
|     <script> | ||||
|  | ||||
							
								
								
									
										50
									
								
								games/templates/view_purchase.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								games/templates/view_purchase.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | ||||
| <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"> | ||||
|         <div class="font-bold font-serif text-slate-500 text-2xl"> | ||||
|         {% if not purchase.name %} | ||||
|             Unnamed purchase | ||||
|         {% else %} | ||||
|             {{ purchase.name }} | ||||
|         {% endif %} | ||||
|         </div> | ||||
|     <span class="text-balance max-w-[30rem] text-4xl"> | ||||
|         <span class="font-bold font-serif"> | ||||
|             {{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}}) | ||||
|         </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> | ||||
|         <p> | ||||
|             Price: | ||||
|             <c-price-converted>{{ purchase.standardized_price }}</c-price-converted> | ||||
|              ({{ purchase.price | floatformat:2 }} {{ purchase.price_currency }}) | ||||
|         </p> | ||||
|         <p>Price per game: <c-price-converted>{{ purchase.price_per_game | floatformat:0 }} {{ purchase.converted_currency }}</c-price-converted> </p> | ||||
|     </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> | ||||
							
								
								
									
										12
									
								
								games/templatetags/duration_formatter.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								games/templatetags/duration_formatter.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django import template | ||||
|  | ||||
| from common.time import durationformat, format_duration | ||||
|  | ||||
| register = template.Library() | ||||
|  | ||||
|  | ||||
| @register.filter(name="format_duration") | ||||
| def filter_format_duration(duration: timedelta, argument: str = durationformat): | ||||
|     return format_duration(duration, format_string=argument) | ||||
| @ -1,6 +1,16 @@ | ||||
| from django.urls import path | ||||
|  | ||||
| from games.views import device, edition, game, general, platform, purchase, session | ||||
| from games.api import api | ||||
| from games.views import ( | ||||
|     device, | ||||
|     game, | ||||
|     general, | ||||
|     platform, | ||||
|     playevent, | ||||
|     purchase, | ||||
|     session, | ||||
|     statuschange, | ||||
| ) | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("", general.index, name="index"), | ||||
| @ -8,19 +18,6 @@ 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"), | ||||
| @ -38,7 +35,29 @@ urlpatterns = [ | ||||
|         name="delete_platform", | ||||
|     ), | ||||
|     path("platform/list", platform.list_platforms, name="list_platforms"), | ||||
|     path("playevent/list", playevent.list_playevents, name="list_playevents"), | ||||
|     path("playevent/add", playevent.add_playevent, name="add_playevent"), | ||||
|     path( | ||||
|         "playevent/add/for-game/<int:game_id>", | ||||
|         playevent.add_playevent, | ||||
|         name="add_playevent_for_game", | ||||
|     ), | ||||
|     path( | ||||
|         "playevent/edit/<int:playevent_id>", | ||||
|         playevent.edit_playevent, | ||||
|         name="edit_playevent", | ||||
|     ), | ||||
|     path( | ||||
|         "playevent/delete/<int:playevent_id>", | ||||
|         playevent.delete_playevent, | ||||
|         name="delete_playevent", | ||||
|     ), | ||||
|     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, | ||||
| @ -54,6 +73,11 @@ urlpatterns = [ | ||||
|         purchase.delete_purchase, | ||||
|         name="delete_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "purchase/<int:purchase_id>/view", | ||||
|         purchase.view_purchase, | ||||
|         name="view_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "purchase/<int:purchase_id>/finish", | ||||
|         purchase.finish_purchase, | ||||
| @ -70,20 +94,15 @@ urlpatterns = [ | ||||
|         name="refund_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "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", | ||||
|         "purchase/related-purchase-by-game", | ||||
|         purchase.related_purchase_by_game, | ||||
|         name="related_purchase_by_game", | ||||
|     ), | ||||
|     path("session/add", session.add_session, name="add_session"), | ||||
|     path( | ||||
|         "session/add/for-purchase/<int:purchase_id>", | ||||
|         "session/add/for-game/<int:game_id>", | ||||
|         session.add_session, | ||||
|         name="add_session_for_purchase", | ||||
|         name="add_session_for_game", | ||||
|     ), | ||||
|     path( | ||||
|         "session/add/from-game/<int:session_id>", | ||||
| @ -117,10 +136,31 @@ urlpatterns = [ | ||||
|     ), | ||||
|     path("session/list", session.list_sessions, name="list_sessions"), | ||||
|     path("session/search", session.search_sessions, name="search_sessions"), | ||||
|     path( | ||||
|         "statuschange/add", | ||||
|         statuschange.AddStatusChangeView.as_view(), | ||||
|         name="add_statuschange", | ||||
|     ), | ||||
|     path( | ||||
|         "statuschange/edit/<int:statuschange_id>", | ||||
|         statuschange.EditStatusChangeView.as_view(), | ||||
|         name="edit_statuschange", | ||||
|     ), | ||||
|     path( | ||||
|         "statuschange/delete/<int:pk>", | ||||
|         statuschange.GameStatusChangeDeleteView.as_view(), | ||||
|         name="delete_statuschange", | ||||
|     ), | ||||
|     path( | ||||
|         "statuschange/list", | ||||
|         statuschange.GameStatusChangeListView.as_view(), | ||||
|         name="list_statuschanges", | ||||
|     ), | ||||
|     path("stats/", general.stats_alltime, name="stats_alltime"), | ||||
|     path( | ||||
|         "stats/<int:year>", | ||||
|         general.stats, | ||||
|         name="stats_by_year", | ||||
|     ), | ||||
|     path("api/", api.urls), | ||||
| ] | ||||
|  | ||||
| @ -1,154 +0,0 @@ | ||||
| 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) | ||||
| @ -2,7 +2,7 @@ from typing import Any | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.core.paginator import Paginator | ||||
| from django.db.models import Prefetch | ||||
| from django.db.models import Prefetch, Q | ||||
| 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 | ||||
| @ -12,32 +12,54 @@ from common.components import ( | ||||
|     A, | ||||
|     Button, | ||||
|     Div, | ||||
|     Form, | ||||
|     Icon, | ||||
|     NameWithPlatformIcon, | ||||
|     LinkedPurchase, | ||||
|     NameWithIcon, | ||||
|     Popover, | ||||
|     PopoverTruncated, | ||||
|     PurchasePrice, | ||||
| ) | ||||
| from common.time import ( | ||||
|     dateformat, | ||||
|     durationformat, | ||||
|     durationformat_manual, | ||||
|     format_duration, | ||||
|     local_strftime, | ||||
|     timeformat, | ||||
| ) | ||||
| from common.utils import format_float_or_int, safe_division, truncate | ||||
| from common.utils import build_dynamic_filter, safe_division, truncate | ||||
| from games.forms import GameForm | ||||
| from games.models import Edition, Game, Purchase, Session | ||||
| from games.models import Game, Purchase | ||||
| from games.views.general import use_custom_redirect | ||||
| from games.views.playevent import create_playevent_tabledata | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def list_games(request: HttpRequest) -> HttpResponse: | ||||
| def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse: | ||||
|     context: dict[Any, Any] = {} | ||||
|     page_number = request.GET.get("page", 1) | ||||
|     limit = request.GET.get("limit", 10) | ||||
|     games = Game.objects.order_by("-created_at") | ||||
|     page_obj = None | ||||
|     search_string = request.GET.get("search_string", search_string) | ||||
|     if search_string != "": | ||||
|         filters = [ | ||||
|             Q(name__icontains=search_string), | ||||
|             Q(sort_name__icontains=search_string), | ||||
|             Q(platform__name__icontains=search_string), | ||||
|         ] | ||||
|         try: | ||||
|             year_value = int(search_string) | ||||
|         except ValueError: | ||||
|             year_value = None | ||||
|         if year_value: | ||||
|             filters.append(Q(year_released=year_value)) | ||||
|         search_string_parts = search_string.split() | ||||
|         # only search for status if it exactly matches and is the only word | ||||
|         if len(search_string_parts) == 1: | ||||
|             if search_string.title() in Game.Status.labels: | ||||
|                 search_status = Game.Status[search_string.upper()] | ||||
|                 filters.append(Q(status=search_status)) | ||||
|         games = games.filter(build_dynamic_filter(filters, "|")) | ||||
|     if int(limit) != 0: | ||||
|         paginator = Paginator(games, limit) | ||||
|         page_obj = paginator.get_page(page_number) | ||||
| @ -54,35 +76,45 @@ def list_games(request: HttpRequest) -> HttpResponse: | ||||
|             else None | ||||
|         ), | ||||
|         "data": { | ||||
|             "header_action": A([], Button([], "Add game"), url="add_game"), | ||||
|             "header_action": Div( | ||||
|                 children=[ | ||||
|                     Form( | ||||
|                         children=[ | ||||
|                             render_to_string( | ||||
|                                 "cotton/search_field.html", | ||||
|                                 { | ||||
|                                     "id": "search_string", | ||||
|                                     "search_string": search_string, | ||||
|                                 }, | ||||
|                             ) | ||||
|                         ] | ||||
|                     ), | ||||
|                     A([], Button([], "Add game"), url="add_game"), | ||||
|                 ], | ||||
|                 attributes=[("class", "flex justify-between")], | ||||
|             ), | ||||
|             "columns": [ | ||||
|                 "Name", | ||||
|                 "Sort Name", | ||||
|                 "Year", | ||||
|                 "Status", | ||||
|                 "Wikidata", | ||||
|                 "Created", | ||||
|                 "Actions", | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     A( | ||||
|                         [ | ||||
|                             ( | ||||
|                                 "href", | ||||
|                                 reverse( | ||||
|                                     "view_game", | ||||
|                                     args=[game.pk], | ||||
|                                 ), | ||||
|                             ) | ||||
|                         ], | ||||
|                         PopoverTruncated(game.name), | ||||
|                     ), | ||||
|                     NameWithIcon(game_id=game.pk), | ||||
|                     PopoverTruncated( | ||||
|                         game.sort_name | ||||
|                         if game.sort_name is not None and game.name != game.sort_name | ||||
|                         else "(identical)" | ||||
|                     ), | ||||
|                     game.year_released, | ||||
|                     render_to_string( | ||||
|                         "cotton/gamestatus.html", | ||||
|                         {"status": game.status, "slot": game.get_status_display()}, | ||||
|                     ), | ||||
|                     game.wikidata, | ||||
|                     local_strftime(game.created_at, dateformat), | ||||
|                     render_to_string( | ||||
| @ -118,7 +150,7 @@ def add_game(request: HttpRequest) -> HttpResponse: | ||||
|         game = form.save() | ||||
|         if "submit_and_redirect" in request.POST: | ||||
|             return HttpResponseRedirect( | ||||
|                 reverse("add_edition_for_game", kwargs={"game_id": game.id}) | ||||
|                 reverse("add_purchase_for_game", kwargs={"game_id": game.id}) | ||||
|             ) | ||||
|         else: | ||||
|             return redirect("list_games") | ||||
| @ -161,29 +193,20 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|         to_attr="nongame_related_purchases", | ||||
|     ) | ||||
|     game_purchases_prefetch: Prefetch[Purchase] = Prefetch( | ||||
|         "purchase_set", | ||||
|         "purchases", | ||||
|         queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related( | ||||
|             nongame_related_purchases_prefetch | ||||
|         ), | ||||
|         to_attr="game_purchases", | ||||
|     ) | ||||
|     editions = ( | ||||
|         Edition.objects.filter(game=game) | ||||
|         .prefetch_related(game_purchases_prefetch) | ||||
|         .order_by("year_released") | ||||
|     ) | ||||
|  | ||||
|     purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased") | ||||
|     purchases = game.purchases.order_by("date_purchased") | ||||
|  | ||||
|     sessions = Session.objects.prefetch_related("device").filter( | ||||
|         purchase__edition__game=game | ||||
|     ) | ||||
|     sessions = game.sessions | ||||
|     session_count = sessions.count() | ||||
|     session_count_without_manual = ( | ||||
|         Session.objects.without_manual().filter(purchase__edition__game=game).count() | ||||
|     ) | ||||
|     session_count_without_manual = game.sessions.without_manual().count() | ||||
|  | ||||
|     if sessions: | ||||
|     if sessions.exists(): | ||||
|         playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y") | ||||
|         latest_session = sessions.latest() | ||||
|         playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y") | ||||
| @ -202,52 +225,14 @@ 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": [ | ||||
|             [ | ||||
|                 NameWithPlatformIcon( | ||||
|                     name=purchase.name if purchase.name else purchase.edition.name, | ||||
|                     platform=purchase.platform, | ||||
|                 ), | ||||
|                 LinkedPurchase(purchase), | ||||
|                 purchase.get_type_display(), | ||||
|                 purchase.date_purchased.strftime(dateformat), | ||||
|                 f"{format_float_or_int(purchase.price)} {purchase.price_currency}", | ||||
|                 PurchasePrice(purchase), | ||||
|                 render_to_string( | ||||
|                     "cotton/button_group.html", | ||||
|                     { | ||||
| @ -270,9 +255,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|     sessions_all = Session.objects.filter(purchase__edition__game=game).order_by( | ||||
|         "-timestamp_start" | ||||
|     ) | ||||
|     sessions_all = game.sessions.order_by("-timestamp_start") | ||||
|  | ||||
|     last_session = None | ||||
|     if sessions_all.exists(): | ||||
|         last_session = sessions_all.latest() | ||||
| @ -299,7 +283,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|                         args=[last_session.pk], | ||||
|                     ), | ||||
|                     children=Popover( | ||||
|                         popover_content=last_session.purchase.edition.name, | ||||
|                         popover_content=last_session.game.name, | ||||
|                         children=[ | ||||
|                             Button( | ||||
|                                 icon=True, | ||||
| @ -307,7 +291,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|                                 size="xs", | ||||
|                                 children=[ | ||||
|                                     Icon("play"), | ||||
|                                     truncate(f"{last_session.purchase.edition.name}"), | ||||
|                                     truncate(f"{last_session.game.name}"), | ||||
|                                 ], | ||||
|                             ) | ||||
|                         ], | ||||
| @ -317,21 +301,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|                 else "", | ||||
|             ], | ||||
|         ), | ||||
|         "columns": ["Edition", "Date", "Duration", "Actions"], | ||||
|         "columns": ["Game", "Date", "Duration", "Actions"], | ||||
|         "rows": [ | ||||
|             [ | ||||
|                 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 ""}", | ||||
|                 ( | ||||
|                     format_duration(session.duration_calculated, durationformat) | ||||
|                     if session.duration_calculated | ||||
|                     else f"{format_duration(session.duration_manual, durationformat_manual)}*" | ||||
|                 NameWithIcon( | ||||
|                     session_id=session.pk, | ||||
|                 ), | ||||
|                 f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", | ||||
|                 session.duration_formatted_with_mark, | ||||
|                 render_to_string( | ||||
|                     "cotton/button_group.html", | ||||
|                     { | ||||
| @ -369,12 +346,37 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|     playevents = game.playevents.all() | ||||
|     playevent_count = playevents.count() | ||||
|     playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"]) | ||||
|  | ||||
|     statuschanges = game.status_changes.all() | ||||
|     statuschange_count = statuschanges.count() | ||||
|     statuschange_data = { | ||||
|         "columns": [ | ||||
|             "Old Status", | ||||
|             "New Status", | ||||
|             "Timestamp", | ||||
|         ], | ||||
|         "rows": [ | ||||
|             [ | ||||
|                 statuschange.get_old_status_display() | ||||
|                 if statuschange.old_status | ||||
|                 else "-", | ||||
|                 statuschange.get_new_status_display(), | ||||
|                 local_strftime(statuschange.timestamp, dateformat), | ||||
|             ] | ||||
|             for statuschange in statuschanges | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|     context: dict[str, Any] = { | ||||
|         "edition_count": editions.count(), | ||||
|         "editions": editions, | ||||
|         "statuschange_data": statuschange_data, | ||||
|         "statuschange_count": statuschange_count, | ||||
|         "statuschanges": statuschanges, | ||||
|         "game": game, | ||||
|         "playrange": playrange, | ||||
|         "purchase_count": Purchase.objects.filter(edition__game=game).count(), | ||||
|         "purchase_count": game.purchases.count(), | ||||
|         "session_average_without_manual": round( | ||||
|             safe_division( | ||||
|                 total_hours_without_manual, int(session_count_without_manual) | ||||
| @ -385,8 +387,9 @@ 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, | ||||
|         "playevent_data": playevent_data, | ||||
|         "playevent_count": playevent_count, | ||||
|         "session_data": session_data, | ||||
|         "session_page_obj": session_page_obj, | ||||
|         "session_elided_page_range": ( | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user