Compare commits
	
		
			34 Commits
		
	
	
		
			remove_edi
			...
			37bcab73f0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 37bcab73f0 | |||
| 1a8338c0f8 | |||
| 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 | 
| @ -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 | ||||
|  | ||||
| @ -9,7 +9,7 @@ from django.urls import NoReverseMatch, reverse | ||||
| from django.utils.safestring import SafeText, mark_safe | ||||
|  | ||||
| from common.utils import truncate | ||||
| from games.models import Purchase | ||||
| from games.models import Game, Purchase, Session | ||||
|  | ||||
| HTMLAttribute = tuple[str, str | int | bool] | ||||
| HTMLTag = str | ||||
| @ -32,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}>" | ||||
| @ -188,49 +188,28 @@ def Icon( | ||||
|     return result | ||||
|  | ||||
|  | ||||
| def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText: | ||||
|     link = reverse("view_game", args=[int(game_id)]) | ||||
|     a_content = Div( | ||||
|         [("class", "inline-flex gap-2 items-center")], | ||||
|         [ | ||||
|             Icon( | ||||
|                 platform.icon, | ||||
|                 [("title", platform.name)], | ||||
|             ), | ||||
|             PopoverTruncated(name), | ||||
|         ], | ||||
|     ) | ||||
|  | ||||
|     return mark_safe( | ||||
|         A( | ||||
|             url=link, | ||||
|             children=[a_content], | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def LinkedPurchase(purchase: Purchase) -> SafeText: | ||||
|     link = reverse("view_purchase", args=[int(purchase.id)]) | ||||
|     link_content = "" | ||||
|     popover_content = "" | ||||
|     edition_count = purchase.editions.count() | ||||
|     game_count = purchase.games.count() | ||||
|     popover_if_not_truncated = False | ||||
|     if edition_count == 1: | ||||
|         link_content += purchase.editions.first().name | ||||
|     if game_count == 1: | ||||
|         link_content += purchase.games.first().name | ||||
|         popover_content = link_content | ||||
|     if edition_count > 1: | ||||
|     if game_count > 1: | ||||
|         if purchase.name: | ||||
|             link_content += f"{purchase.name}" | ||||
|             popover_content += f"<h1>{purchase.name}</h1><br>" | ||||
|         else: | ||||
|             link_content += f"{edition_count} games" | ||||
|             link_content += f"{game_count} games" | ||||
|             popover_if_not_truncated = True | ||||
|         popover_content += f""" | ||||
|         <ul class="list-disc list-inside"> | ||||
|             {"".join(f"<li>{edition.name}</li>" for edition in purchase.editions.all())} | ||||
|             {"".join(f"<li>{game.name}</li>" for game in purchase.games.all())} | ||||
|         </ul> | ||||
|         """ | ||||
|     icon = purchase.platform.icon if edition_count == 1 else "unspecified" | ||||
|     icon = purchase.platform.icon if game_count == 1 else "unspecified" | ||||
|     if link_content == "": | ||||
|         raise ValueError("link_content is empty!!") | ||||
|     a_content = Div( | ||||
| @ -250,19 +229,54 @@ def LinkedPurchase(purchase: Purchase) -> SafeText: | ||||
|     return mark_safe(A(url=link, children=[a_content])) | ||||
|  | ||||
|  | ||||
| def NameWithPlatformIcon(name: str, platform: str) -> SafeText: | ||||
| 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), | ||||
|         ], | ||||
|     ) | ||||
|  | ||||
|     return mark_safe(content) | ||||
|     return mark_safe( | ||||
|         A( | ||||
|             url=link, | ||||
|             children=[content], | ||||
|         ) | ||||
|         if create_link | ||||
|         else content, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def PurchasePrice(purchase) -> str: | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,10 @@ | ||||
| import operator | ||||
| from dataclasses import dataclass | ||||
| from datetime import date | ||||
| from typing import Any, Generator, TypeVar | ||||
| from functools import reduce | ||||
| from typing import Any, Callable, Generator, Literal, TypeVar | ||||
|  | ||||
| from django.db.models import Q | ||||
|  | ||||
| def safe_division(numerator: int | float, denominator: int | float) -> int | float: | ||||
|     """ | ||||
| @ -81,3 +85,46 @@ def generate_split_ranges( | ||||
|  | ||||
| def format_float_or_int(number: int | float): | ||||
|     return int(number) if float(number).is_integer() else f"{number:03.2f}" | ||||
|  | ||||
|  | ||||
| OperatorType = Literal["|", "&"] | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class FilterEntry: | ||||
|     condition: Q | ||||
|     operator: OperatorType = "&" | ||||
|  | ||||
|  | ||||
| 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(), | ||||
|     ) | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -11,6 +11,8 @@ class GamesConfig(AppConfig): | ||||
|     name = "games" | ||||
|  | ||||
|     def ready(self): | ||||
|         import games.signals  # noqa: F401 | ||||
|  | ||||
|         post_migrate.connect(schedule_tasks, sender=self) | ||||
|  | ||||
|  | ||||
| @ -24,6 +26,16 @@ def schedule_tasks(sender, **kwargs): | ||||
|             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 | ||||
|  | ||||
| @ -2,7 +2,7 @@ from django import forms | ||||
| 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, Platform, Purchase, Session | ||||
|  | ||||
| custom_date_widget = forms.DateInput(attrs={"type": "date"}) | ||||
| custom_datetime_widget = forms.DateTimeInput( | ||||
| @ -11,17 +11,30 @@ 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.all(), | ||||
|     game = SingleGameChoiceField( | ||||
|         queryset=Game.objects.order_by("sort_name"), | ||||
|         widget=forms.Select(attrs={"autofocus": "autofocus"}), | ||||
|     ) | ||||
|  | ||||
|     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,18 +42,27 @@ class SessionForm(forms.ModelForm): | ||||
|         } | ||||
|         model = Session | ||||
|         fields = [ | ||||
|             "purchase", | ||||
|             "game", | ||||
|             "timestamp_start", | ||||
|             "timestamp_end", | ||||
|             "duration_manual", | ||||
|             "emulated", | ||||
|             "device", | ||||
|             "note", | ||||
|             "mark_as_played", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class EditionChoiceField(forms.ModelMultipleChoiceField): | ||||
|     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.SelectMultiple): | ||||
| @ -56,19 +78,19 @@ 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["editions"].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", | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     editions = 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")) | ||||
| @ -86,7 +108,7 @@ class PurchaseForm(forms.ModelForm): | ||||
|         } | ||||
|         model = Purchase | ||||
|         fields = [ | ||||
|             "editions", | ||||
|             "games", | ||||
|             "platform", | ||||
|             "date_purchased", | ||||
|             "date_refunded", | ||||
| @ -138,24 +160,22 @@ 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", | ||||
|             "year_released", | ||||
|             "status", | ||||
|             "mastered", | ||||
|             "wikidata", | ||||
|         ] | ||||
|         widgets = {"name": autofocus_input_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" | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @ -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)] | ||||
| @ -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" | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -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", | ||||
|         ), | ||||
|     ] | ||||
| @ -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), | ||||
|         ), | ||||
|     ] | ||||
| @ -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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -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')}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # Generated by Django 5.1.3 on 2025-01-07 20:14 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0041_purchase_converted_currency_purchase_converted_price_and_more'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='purchase', | ||||
|             name='editions_temp', | ||||
|             field=models.ManyToManyField(blank=True, related_name='temp_purchases', to='games.edition'), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,27 +0,0 @@ | ||||
| # Generated by Django 5.1.3 on 2025-01-07 20:17 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def migrate_edition_to_editions_temp(apps, schema_editor): | ||||
|     Purchase = apps.get_model("games", "Purchase") | ||||
|     for purchase in Purchase.objects.all(): | ||||
|         if purchase.edition: | ||||
|             print( | ||||
|                 f"Migrating Purchase {purchase.id} with Edition {purchase.edition.id}" | ||||
|             ) | ||||
|             purchase.editions_temp.add(purchase.edition) | ||||
|             print(purchase.editions_temp.all()) | ||||
|             purchase.save() | ||||
|         else: | ||||
|             print(f"No edition found for Purchase {purchase.id}") | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("games", "0042_purchase_editions_temp"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(migrate_edition_to_editions_temp), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # Generated by Django 5.1.3 on 2025-01-07 20:32 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("games", "0043_auto_20250107_2117"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField(model_name="purchase", name="edition"), | ||||
|         migrations.RenameField( | ||||
|             model_name="purchase", | ||||
|             old_name="editions_temp", | ||||
|             new_name="editions", | ||||
|         ), | ||||
|     ] | ||||
| @ -1,18 +0,0 @@ | ||||
| # Generated by Django 5.1.3 on 2025-01-07 20:37 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('games', '0044_auto_20250107_2132'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='purchase', | ||||
|             name='editions', | ||||
|             field=models.ManyToManyField(blank=True, related_name='purchases', to='games.edition'), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										143
									
								
								games/models.py
									
									
									
									
									
								
							
							
						
						
									
										143
									
								
								games/models.py
									
									
									
									
									
								
							| @ -3,18 +3,51 @@ from datetime import timedelta | ||||
| 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.template.defaultfilters import floatformat, pluralize, slugify | ||||
| from django.utils import timezone | ||||
|  | ||||
| from common.time import format_duration | ||||
|  | ||||
|  | ||||
| class Game(models.Model): | ||||
|     class Meta: | ||||
|         unique_together = [["name", "platform", "year_released"]] | ||||
|  | ||||
|     name = models.CharField(max_length=255) | ||||
|     sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||
|     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) | ||||
|     wikidata = models.CharField(max_length=50, blank=True, default="") | ||||
|     platform = models.ForeignKey( | ||||
|         "Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None | ||||
|     ) | ||||
|  | ||||
|     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,19 +55,9 @@ class Game(models.Model): | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
|  | ||||
| class Platform(models.Model): | ||||
|     name = models.CharField(max_length=255) | ||||
|     group = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||
|     icon = models.SlugField(blank=True) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         if not self.icon: | ||||
|             self.icon = slugify(self.name) | ||||
|         if self.platform is None: | ||||
|             self.platform = get_sentinel_platform() | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| @ -44,26 +67,18 @@ def get_sentinel_platform(): | ||||
|     )[0] | ||||
|  | ||||
|  | ||||
| class Edition(models.Model): | ||||
|     class Meta: | ||||
|         unique_together = [["name", "platform", "year_released"]] | ||||
|  | ||||
|     game = models.ForeignKey(Game, on_delete=models.CASCADE) | ||||
| class Platform(models.Model): | ||||
|     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) | ||||
|     group = models.CharField(max_length=255, blank=True, default="") | ||||
|     icon = models.SlugField(blank=True) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.sort_name | ||||
|         return self.name | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         if self.platform is None: | ||||
|             self.platform = get_sentinel_platform() | ||||
|         if not self.icon: | ||||
|             self.icon = slugify(self.name) | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| @ -113,7 +128,8 @@ class Purchase(models.Model): | ||||
|  | ||||
|     objects = PurchaseQueryset().as_manager() | ||||
|  | ||||
|     editions = models.ManyToManyField(Edition, related_name="purchases", blank=True) | ||||
|     games = models.ManyToManyField(Game, related_name="purchases") | ||||
|  | ||||
|     platform = models.ForeignKey( | ||||
|         Platform, on_delete=models.CASCADE, default=None, null=True, blank=True | ||||
|     ) | ||||
| @ -125,38 +141,59 @@ class Purchase(models.Model): | ||||
|     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 = models.FloatField(null=True) | ||||
|     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 first_edition(self): | ||||
|         return self.editions.first() | ||||
|     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.first_edition.platform} version on {self.platform}" | ||||
|                 if self.platform != self.first_edition.platform | ||||
|                 else self.platform | ||||
|             ), | ||||
|             self.first_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.first_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 | ||||
| @ -207,7 +244,13 @@ class Session(models.Model): | ||||
|     class Meta: | ||||
|         get_latest_by = "timestamp_start" | ||||
|  | ||||
|     purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE) | ||||
|     game = models.ForeignKey( | ||||
|         Game, | ||||
|         on_delete=models.CASCADE, | ||||
|         null=True, | ||||
|         default=None, | ||||
|         related_name="sessions", | ||||
|     ) | ||||
|     timestamp_start = models.DateTimeField() | ||||
|     timestamp_end = models.DateTimeField(blank=True, null=True) | ||||
|     duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) | ||||
| @ -219,7 +262,9 @@ 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) | ||||
|  | ||||
| @ -227,7 +272,7 @@ class Session(models.Model): | ||||
|  | ||||
|     def __str__(self): | ||||
|         mark = ", manual" if self.is_manual() else "" | ||||
|         return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" | ||||
|         return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" | ||||
|  | ||||
|     def finish_now(self): | ||||
|         self.timestamp_end = timezone.now() | ||||
| @ -240,7 +285,7 @@ class Session(models.Model): | ||||
|         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: | ||||
|         if self.timestamp_end is not None and self.timestamp_start is not None: | ||||
|             calculated = self.timestamp_end - self.timestamp_start | ||||
|         return timedelta(seconds=(manual + calculated).total_seconds()) | ||||
|  | ||||
| @ -256,7 +301,7 @@ class Session(models.Model): | ||||
|         return Session.objects.all().total_duration_formatted() | ||||
|  | ||||
|     def save(self, *args, **kwargs) -> None: | ||||
|         if self.timestamp_start != None and self.timestamp_end != None: | ||||
|         if self.timestamp_start is not None and self.timestamp_end is not None: | ||||
|             self.duration_calculated = self.timestamp_end - self.timestamp_start | ||||
|         else: | ||||
|             self.duration_calculated = timedelta(0) | ||||
|  | ||||
							
								
								
									
										12
									
								
								games/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								games/signals.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| from django.db.models.signals import m2m_changed | ||||
| from django.dispatch import receiver | ||||
| from django.utils.timezone import now | ||||
|  | ||||
| from games.models import Purchase | ||||
|  | ||||
|  | ||||
| @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"]) | ||||
| @ -1299,6 +1299,10 @@ input:checked + .toggle-bg { | ||||
|   bottom: 0px; | ||||
| } | ||||
|  | ||||
| .-left-3 { | ||||
|   left: -0.75rem; | ||||
| } | ||||
|  | ||||
| .bottom-0 { | ||||
|   bottom: 0px; | ||||
| } | ||||
| @ -1335,6 +1339,10 @@ input:checked + .toggle-bg { | ||||
|   top: 0px; | ||||
| } | ||||
|  | ||||
| .top-2 { | ||||
|   top: 0.5rem; | ||||
| } | ||||
|  | ||||
| .top-3 { | ||||
|   top: 0.75rem; | ||||
| } | ||||
| @ -1415,6 +1423,10 @@ input:checked + .toggle-bg { | ||||
|   margin-inline-end: 0.5rem; | ||||
| } | ||||
|  | ||||
| .ml-3 { | ||||
|   margin-left: 0.75rem; | ||||
| } | ||||
|  | ||||
| .mr-4 { | ||||
|   margin-right: 1rem; | ||||
| } | ||||
| @ -1443,10 +1455,6 @@ input:checked + .toggle-bg { | ||||
|   margin-top: 1rem; | ||||
| } | ||||
|  | ||||
| .ml-4 { | ||||
|   margin-left: 1rem; | ||||
| } | ||||
|  | ||||
| .block { | ||||
|   display: block; | ||||
| } | ||||
| @ -1475,10 +1483,6 @@ input:checked + .toggle-bg { | ||||
|   display: grid; | ||||
| } | ||||
|  | ||||
| .list-item { | ||||
|   display: list-item; | ||||
| } | ||||
|  | ||||
| .hidden { | ||||
|   display: none; | ||||
| } | ||||
| @ -1496,6 +1500,10 @@ input:checked + .toggle-bg { | ||||
|   height: 3rem; | ||||
| } | ||||
|  | ||||
| .h-2 { | ||||
|   height: 0.5rem; | ||||
| } | ||||
|  | ||||
| .h-2\.5 { | ||||
|   height: 0.625rem; | ||||
| } | ||||
| @ -1532,6 +1540,10 @@ input:checked + .toggle-bg { | ||||
|   width: 2.5rem; | ||||
| } | ||||
|  | ||||
| .w-2 { | ||||
|   width: 0.5rem; | ||||
| } | ||||
|  | ||||
| .w-2\.5 { | ||||
|   width: 0.625rem; | ||||
| } | ||||
| @ -1584,6 +1596,10 @@ input:checked + .toggle-bg { | ||||
|   max-width: 24rem; | ||||
| } | ||||
|  | ||||
| .max-w-xl { | ||||
|   max-width: 36rem; | ||||
| } | ||||
|  | ||||
| .max-w-xs { | ||||
|   max-width: 20rem; | ||||
| } | ||||
| @ -1712,6 +1728,10 @@ input:checked + .toggle-bg { | ||||
|   justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .gap-1 { | ||||
|   gap: 0.25rem; | ||||
| } | ||||
|  | ||||
| .gap-2 { | ||||
|   gap: 0.5rem; | ||||
| } | ||||
| @ -1795,6 +1815,10 @@ input:checked + .toggle-bg { | ||||
|   border-radius: 0.125rem; | ||||
| } | ||||
|  | ||||
| .rounded-xl { | ||||
|   border-radius: 0.75rem; | ||||
| } | ||||
|  | ||||
| .rounded-e-lg { | ||||
|   border-start-end-radius: 0.5rem; | ||||
|   border-end-end-radius: 0.5rem; | ||||
| @ -1888,6 +1912,11 @@ 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)); | ||||
| @ -1897,6 +1926,11 @@ input:checked + .toggle-bg { | ||||
|   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)); | ||||
| @ -1907,6 +1941,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)); | ||||
| @ -2185,6 +2234,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)); | ||||
| @ -2357,10 +2411,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; | ||||
| @ -2399,25 +2452,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 { | ||||
| @ -2433,21 +2486,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; | ||||
| @ -2556,6 +2609,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)); | ||||
|  | ||||
| @ -7,7 +7,7 @@ import { | ||||
|  | ||||
| let syncData = [ | ||||
|   { | ||||
|     source: "#id_edition", | ||||
|     source: "#id_games", | ||||
|     source_value: "dataset.platform", | ||||
|     target: "#id_platform", | ||||
|     target_value: "value", | ||||
| @ -36,8 +36,8 @@ getEl("#id_type").onchange = () => { | ||||
|  | ||||
| document.body.addEventListener("htmx:beforeRequest", function (event) { | ||||
|   // Assuming 'Purchase1' is the element that triggers the HTMX request | ||||
|   if (event.target.id === "id_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> | ||||
|  | ||||
							
								
								
									
										16
									
								
								games/templates/cotton/gamestatus.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								games/templates/cotton/gamestatus.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| <div class="relative ml-3"> | ||||
|     <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 }} | ||||
| </div> | ||||
							
								
								
									
										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 %} | ||||
|  | ||||
							
								
								
									
										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> | ||||
| @ -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,10 +102,6 @@ | ||||
|                                 <a href="{% url 'list_games' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_editions' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_platforms' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a> | ||||
|  | ||||
| @ -2,11 +2,11 @@ | ||||
| {% load static %} | ||||
| {% partialdef purchase-name %} | ||||
| {% if purchase.type != 'game' %} | ||||
|     <c-gamelink :game_id=purchase.first_edition.game.id> | ||||
|     {{ purchase.name }} ({{ purchase.first_edition.edition.name }} {{ purchase.get_type_display }}) | ||||
|     <c-gamelink :game_id=purchase.first_game.id> | ||||
|     {{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }}) | ||||
|     </c-gamelink> | ||||
| {% else %} | ||||
|     <c-gamelink :game_id=purchase.first_edition.game.id :name=purchase.first_edition.name /> | ||||
|     <c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name /> | ||||
| {% endif %} | ||||
| {% endpartialdef %} | ||||
| <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> | ||||
| @ -46,7 +46,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> | ||||
| @ -148,7 +148,7 @@ | ||||
|             </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> | ||||
|  | ||||
| @ -52,6 +52,19 @@ | ||||
|                 {{ playrange }} | ||||
|                 </c-popover> | ||||
|             </div> | ||||
|             <div class="mb-6 text-slate-400"> | ||||
|                 <div class="flex gap-2 items-center"> | ||||
|                     <span class="uppercase font-bold text-slate-300">Status</span> | ||||
|                     <c-gamestatus :status="game.status"> | ||||
|                         {{ game.get_status_display }} | ||||
|                     </c-gamestatus> | ||||
|                     {% if game.mastered %}👑{% endif %} | ||||
|                 </div> | ||||
|                 <div class="flex gap-2 items-center"> | ||||
|                     <span class="uppercase font-bold text-slate-300">Platform</span> | ||||
|                     <span>{{ 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 +80,21 @@ | ||||
|                 </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> | ||||
|     </div> | ||||
|     <script> | ||||
|  | ||||
| @ -2,8 +2,17 @@ | ||||
|     <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">{% if purchase.name %}{{ purchase.name }}{% else %}Unnamed purchase{% endif %}</span> <span class="text-slate-500 text-2xl">({{ purchase.editions.count }} games)</span> | ||||
|         <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 %}"> | ||||
| @ -19,12 +28,19 @@ | ||||
|             </button> | ||||
|         </a> | ||||
|     </div> | ||||
|     <div>Price: {{ purchase.converted_price | floatformat }} {{ purchase.converted_currency }} ({{ purchase.price | floatformat }} {{ purchase.price_currency }})</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 edition in purchase.editions.all %} | ||||
|         <li><c-gamelink :game_id=edition.game.id :name=edition.name /></li> | ||||
|         {% for game in purchase.games.all %} | ||||
|         <li><c-gamelink :game_id=game.id :name=game.name /></li> | ||||
|         {% endfor %} | ||||
|         </ul> | ||||
|     </div> | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| from django.urls import path | ||||
|  | ||||
| from games.views import device, edition, game, general, platform, purchase, session | ||||
| from games.views import device, game, general, platform, purchase, session | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("", general.index, name="index"), | ||||
| @ -8,19 +8,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"), | ||||
| @ -39,6 +26,11 @@ urlpatterns = [ | ||||
|     ), | ||||
|     path("platform/list", platform.list_platforms, name="list_platforms"), | ||||
|     path("purchase/add", purchase.add_purchase, name="add_purchase"), | ||||
|     path( | ||||
|         "purchase/add/for-game/<int:game_id>", | ||||
|         purchase.add_purchase, | ||||
|         name="add_purchase_for_game", | ||||
|     ), | ||||
|     path( | ||||
|         "purchase/<int:purchase_id>/edit", | ||||
|         purchase.edit_purchase, | ||||
| @ -75,20 +67,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>", | ||||
|  | ||||
| @ -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,9 +12,10 @@ from common.components import ( | ||||
|     A, | ||||
|     Button, | ||||
|     Div, | ||||
|     Form, | ||||
|     Icon, | ||||
|     LinkedPurchase, | ||||
|     NameWithPlatformIcon, | ||||
|     NameWithIcon, | ||||
|     Popover, | ||||
|     PopoverTruncated, | ||||
|     PurchasePrice, | ||||
| @ -27,19 +28,39 @@ from common.time import ( | ||||
|     local_strftime, | ||||
|     timeformat, | ||||
| ) | ||||
| from common.utils import 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 | ||||
|  | ||||
|  | ||||
| @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) | ||||
| @ -56,35 +77,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( | ||||
| @ -120,7 +151,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") | ||||
| @ -169,23 +200,14 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|         ), | ||||
|         to_attr="game_purchases", | ||||
|     ) | ||||
|     editions = ( | ||||
|         Edition.objects.filter(game=game) | ||||
|         .prefetch_related(game_purchases_prefetch) | ||||
|         .order_by("year_released") | ||||
|     ) | ||||
|  | ||||
|     purchases = Purchase.objects.filter(editions__game=game).order_by("date_purchased") | ||||
|     purchases = game.purchases.order_by("date_purchased") | ||||
|  | ||||
|     sessions = Session.objects.prefetch_related("device").filter( | ||||
|         purchase__editions__game=game | ||||
|     ) | ||||
|     sessions = game.sessions | ||||
|     session_count = sessions.count() | ||||
|     session_count_without_manual = ( | ||||
|         Session.objects.without_manual().filter(purchase__editions__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") | ||||
| @ -204,41 +226,6 @@ 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": [ | ||||
| @ -269,9 +256,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|     sessions_all = Session.objects.filter(purchase__editions__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() | ||||
| @ -298,7 +284,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|                         args=[last_session.pk], | ||||
|                     ), | ||||
|                     children=Popover( | ||||
|                         popover_content=last_session.purchase.first_edition.name, | ||||
|                         popover_content=last_session.game.name, | ||||
|                         children=[ | ||||
|                             Button( | ||||
|                                 icon=True, | ||||
| @ -306,9 +292,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|                                 size="xs", | ||||
|                                 children=[ | ||||
|                                     Icon("play"), | ||||
|                                     truncate( | ||||
|                                         f"{last_session.purchase.first_edition.name}" | ||||
|                                     ), | ||||
|                                     truncate(f"{last_session.game.name}"), | ||||
|                                 ], | ||||
|                             ) | ||||
|                         ], | ||||
| @ -318,16 +302,13 @@ 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.first_edition.name, | ||||
|                     platform=session.purchase.platform, | ||||
|                 NameWithIcon( | ||||
|                     session_id=session.pk, | ||||
|                 ), | ||||
|                 f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", | ||||
|                 f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", | ||||
|                 ( | ||||
|                     format_duration(session.duration_calculated, durationformat) | ||||
|                     if session.duration_calculated | ||||
| @ -371,11 +352,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|     } | ||||
|  | ||||
|     context: dict[str, Any] = { | ||||
|         "edition_count": editions.count(), | ||||
|         "editions": editions, | ||||
|         "game": game, | ||||
|         "playrange": playrange, | ||||
|         "purchase_count": Purchase.objects.filter(editions__game=game).count(), | ||||
|         "purchase_count": game.purchases.count(), | ||||
|         "session_average_without_manual": round( | ||||
|             safe_division( | ||||
|                 total_hours_without_manual, int(session_count_without_manual) | ||||
| @ -386,7 +365,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|         "sessions": sessions, | ||||
|         "title": f"Game Overview - {game.name}", | ||||
|         "hours_sum": total_hours, | ||||
|         "edition_data": edition_data, | ||||
|         "purchase_data": purchase_data, | ||||
|         "session_data": session_data, | ||||
|         "session_page_obj": session_page_obj, | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| from datetime import datetime | ||||
| from datetime import datetime, timedelta | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
| @ -8,19 +8,32 @@ from django.db.models.manager import BaseManager | ||||
| from django.http import HttpRequest, HttpResponse, HttpResponseRedirect | ||||
| from django.shortcuts import redirect, render | ||||
| from django.urls import reverse | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from common.time import available_stats_year_range, dateformat, format_duration | ||||
| from common.utils import safe_division | ||||
| from games.models import Edition, Game, Platform, Purchase, Session | ||||
| from games.models import Game, Platform, Purchase, Session | ||||
|  | ||||
|  | ||||
| def model_counts(request: HttpRequest) -> dict[str, bool]: | ||||
|     now = timezone_now() | ||||
|     this_day, this_month, this_year = now.day, now.month, now.year | ||||
|     today_played = Session.objects.filter( | ||||
|         timestamp_start__day=this_day, | ||||
|         timestamp_start__month=this_month, | ||||
|         timestamp_start__year=this_year, | ||||
|     ).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"] | ||||
|     last_7_played = Session.objects.filter( | ||||
|         timestamp_start__gte=(now - timedelta(days=7)) | ||||
|     ).aggregate(time=Sum(F("duration_calculated") + F("duration_manual")))["time"] | ||||
|  | ||||
|     return { | ||||
|         "game_available": Game.objects.exists(), | ||||
|         "edition_available": Edition.objects.exists(), | ||||
|         "platform_available": Platform.objects.exists(), | ||||
|         "purchase_available": Purchase.objects.exists(), | ||||
|         "session_count": Session.objects.exists(), | ||||
|         "today_played": format_duration(today_played, "%H h %m m"), | ||||
|         "last_7_played": format_duration(last_7_played, "%H h %m m"), | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -49,9 +62,7 @@ def use_custom_redirect( | ||||
| @login_required | ||||
| def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|     year = "Alltime" | ||||
|     this_year_sessions = Session.objects.all().prefetch_related( | ||||
|         Prefetch("purchase__editions") | ||||
|     ) | ||||
|     this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game")) | ||||
|     this_year_sessions_with_durations = this_year_sessions.annotate( | ||||
|         duration=ExpressionWrapper( | ||||
|             F("timestamp_end") - F("timestamp_start"), | ||||
| @ -59,11 +70,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|         ) | ||||
|     ) | ||||
|     longest_session = this_year_sessions_with_durations.order_by("-duration").first() | ||||
|     this_year_games = Game.objects.filter( | ||||
|         editions__purchase__session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|     this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct() | ||||
|     this_year_games_with_session_counts = this_year_games.annotate( | ||||
|         session_count=Count("editions__purchase__session"), | ||||
|         session_count=Count("sessions"), | ||||
|     ) | ||||
|     game_highest_session_count = this_year_games_with_session_counts.order_by( | ||||
|         "-session_count" | ||||
| @ -76,11 +85,11 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|         .aggregate(dates=Count("date")) | ||||
|     ) | ||||
|     this_year_played_purchases = Purchase.objects.filter( | ||||
|         session__in=this_year_sessions | ||||
|         games__sessions__in=this_year_sessions | ||||
|     ).distinct() | ||||
|  | ||||
|     this_year_purchases = Purchase.objects.all() | ||||
|     this_year_purchases_with_currency = this_year_purchases.select_related("editions") | ||||
|     this_year_purchases_with_currency = this_year_purchases.select_related("games") | ||||
|     this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( | ||||
|         date_refunded=None | ||||
|     ) | ||||
| @ -129,11 +138,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|     total_spent = this_year_spendings["total_spent"] or 0 | ||||
|  | ||||
|     games_with_playtime = ( | ||||
|         Game.objects.filter(editions__purchase__session__in=this_year_sessions) | ||||
|         Game.objects.filter(sessions__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             total_playtime=Sum( | ||||
|                 F("editions__purchase__session__duration_calculated") | ||||
|                 + F("editions__purchase__session__duration_manual") | ||||
|                 F("sessions__duration_calculated") + F("sessions__duration_manual") | ||||
|             ) | ||||
|         ) | ||||
|         .values("id", "name", "total_playtime") | ||||
| @ -148,10 +156,8 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|         month["playtime"] = format_duration(month["playtime"], "%2.0H") | ||||
|  | ||||
|     highest_session_average_game = ( | ||||
|         Game.objects.filter(editions__purchase__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             session_average=Avg("editions__purchase__session__duration_calculated") | ||||
|         ) | ||||
|         Game.objects.filter(sessions__in=this_year_sessions) | ||||
|         .annotate(session_average=Avg("sessions__duration_calculated")) | ||||
|         .order_by("-session_average") | ||||
|         .first() | ||||
|     ) | ||||
| @ -160,9 +166,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|         game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H") | ||||
|  | ||||
|     total_playtime_per_platform = ( | ||||
|         this_year_sessions.values("purchase__platform__name") | ||||
|         this_year_sessions.values("game__platform__name") | ||||
|         .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) | ||||
|         .annotate(platform_name=F("purchase__platform__name")) | ||||
|         .annotate(platform_name=F("game__platform__name")) | ||||
|         .values("platform_name", "total_playtime") | ||||
|         .order_by("-total_playtime") | ||||
|     ) | ||||
| @ -177,10 +183,10 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|     last_play_date = "N/A" | ||||
|     if this_year_sessions: | ||||
|         first_session = this_year_sessions.earliest() | ||||
|         first_play_game = first_session.purchase.first_edition.game | ||||
|         first_play_game = first_session.game | ||||
|         first_play_date = first_session.timestamp_start.strftime(dateformat) | ||||
|         last_session = this_year_sessions.latest() | ||||
|         last_play_game = last_session.purchase.first_edition.game | ||||
|         last_play_game = last_session.game | ||||
|         last_play_date = last_session.timestamp_start.strftime(dateformat) | ||||
|  | ||||
|     all_purchased_this_year_count = this_year_purchases_with_currency.count() | ||||
| @ -195,7 +201,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|         "total_hours": format_duration( | ||||
|             this_year_sessions.total_duration_unformatted(), "%2.0H" | ||||
|         ), | ||||
|         "total_2023_games": this_year_played_purchases.all().count(), | ||||
|         "total_year_games": this_year_played_purchases.all().count(), | ||||
|         "top_10_games_by_playtime": top_10_games_by_playtime, | ||||
|         "year": year, | ||||
|         "total_playtime_per_platform": total_playtime_per_platform, | ||||
| @ -228,9 +234,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|             if longest_session | ||||
|             else 0 | ||||
|         ), | ||||
|         "longest_session_game": ( | ||||
|             longest_session.purchase.first_edition.game if longest_session else None | ||||
|         ), | ||||
|         "longest_session_game": (longest_session.game if longest_session else None), | ||||
|         "highest_session_count": ( | ||||
|             game_highest_session_count.session_count | ||||
|             if game_highest_session_count | ||||
| @ -268,7 +272,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|         return HttpResponseRedirect(reverse("stats_alltime")) | ||||
|     this_year_sessions = Session.objects.filter( | ||||
|         timestamp_start__year=year | ||||
|     ).prefetch_related("purchase__editions") | ||||
|     ).prefetch_related("game") | ||||
|     this_year_sessions_with_durations = this_year_sessions.annotate( | ||||
|         duration=ExpressionWrapper( | ||||
|             F("timestamp_end") - F("timestamp_start"), | ||||
| @ -276,13 +280,11 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|         ) | ||||
|     ) | ||||
|     longest_session = this_year_sessions_with_durations.order_by("-duration").first() | ||||
|     this_year_games = Game.objects.filter( | ||||
|         edition__purchases__session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|     this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct() | ||||
|     this_year_games_with_session_counts = this_year_games.annotate( | ||||
|         session_count=Count( | ||||
|             "edition__purchases__session", | ||||
|             filter=Q(edition__purchases__session__timestamp_start__year=year), | ||||
|             "sessions", | ||||
|             filter=Q(sessions__timestamp_start__year=year), | ||||
|         ) | ||||
|     ) | ||||
|     game_highest_session_count = this_year_games_with_session_counts.order_by( | ||||
| @ -296,11 +298,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|         .aggregate(dates=Count("date")) | ||||
|     ) | ||||
|     this_year_played_purchases = Purchase.objects.filter( | ||||
|         session__in=this_year_sessions | ||||
|         games__sessions__in=this_year_sessions | ||||
|     ).distinct() | ||||
|  | ||||
|     this_year_played_games = Game.objects.filter( | ||||
|         sessions__in=this_year_sessions | ||||
|     ).distinct() | ||||
|  | ||||
|     this_year_purchases = Purchase.objects.filter(date_purchased__year=year) | ||||
|     this_year_purchases_with_currency = this_year_purchases.prefetch_related("editions") | ||||
|     this_year_purchases_with_currency = this_year_purchases.prefetch_related("games") | ||||
|     this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( | ||||
|         date_refunded=None | ||||
|     ).exclude(ownership_type=Purchase.DEMO) | ||||
| @ -337,7 +343,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|  | ||||
|     purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) | ||||
|     purchases_finished_this_year_released_this_year = ( | ||||
|         purchases_finished_this_year.filter(editions__year_released=year).order_by( | ||||
|         purchases_finished_this_year.filter(games__year_released=year).order_by( | ||||
|             "date_finished" | ||||
|         ) | ||||
|     ) | ||||
| @ -351,11 +357,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|     total_spent = this_year_spendings["total_spent"] or 0 | ||||
|  | ||||
|     games_with_playtime = ( | ||||
|         Game.objects.filter(edition__purchases__session__in=this_year_sessions) | ||||
|         Game.objects.filter(sessions__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             total_playtime=Sum( | ||||
|                 F("edition__purchases__session__duration_calculated") | ||||
|                 + F("edition__purchases__session__duration_manual") | ||||
|                 F("sessions__duration_calculated") + F("sessions__duration_manual") | ||||
|             ) | ||||
|         ) | ||||
|         .values("id", "name", "total_playtime") | ||||
| @ -370,21 +375,19 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|         month["playtime"] = format_duration(month["playtime"], "%2.0H") | ||||
|  | ||||
|     highest_session_average_game = ( | ||||
|         Game.objects.filter(edition__purchases__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             session_average=Avg("edition__purchases__session__duration_calculated") | ||||
|         ) | ||||
|         Game.objects.filter(sessions__in=this_year_sessions) | ||||
|         .annotate(session_average=Avg("sessions__duration_calculated")) | ||||
|         .order_by("-session_average") | ||||
|         .first() | ||||
|     ) | ||||
|     top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] | ||||
|     top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime") | ||||
|     for game in top_10_games_by_playtime: | ||||
|         game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H") | ||||
|  | ||||
|     total_playtime_per_platform = ( | ||||
|         this_year_sessions.values("purchase__platform__name") | ||||
|         this_year_sessions.values("game__platform__name") | ||||
|         .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) | ||||
|         .annotate(platform_name=F("purchase__platform__name")) | ||||
|         .annotate(platform_name=F("game__platform__name")) | ||||
|         .values("platform_name", "total_playtime") | ||||
|         .order_by("-total_playtime") | ||||
|     ) | ||||
| @ -403,10 +406,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|     last_play_game = None | ||||
|     if this_year_sessions: | ||||
|         first_session = this_year_sessions.earliest() | ||||
|         first_play_game = first_session.purchase.first_edition.game | ||||
|         first_play_game = first_session.game | ||||
|         first_play_date = first_session.timestamp_start.strftime(dateformat) | ||||
|         last_session = this_year_sessions.latest() | ||||
|         last_play_game = last_session.purchase.first_edition.game | ||||
|         last_play_game = last_session.game | ||||
|         last_play_date = last_session.timestamp_start.strftime(dateformat) | ||||
|  | ||||
|     all_purchased_this_year_count = this_year_purchases_with_currency.count() | ||||
| @ -421,9 +424,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|         "total_hours": format_duration( | ||||
|             this_year_sessions.total_duration_unformatted(), "%2.0H" | ||||
|         ), | ||||
|         "total_games": this_year_played_purchases.count(), | ||||
|         "total_2023_games": this_year_played_purchases.filter( | ||||
|             editions__year_released=year | ||||
|         "total_games": this_year_played_games.count(), | ||||
|         "total_year_games": this_year_played_purchases.filter( | ||||
|             games__year_released=year | ||||
|         ).count(), | ||||
|         "top_10_games_by_playtime": top_10_games_by_playtime, | ||||
|         "year": year, | ||||
| @ -435,15 +438,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|             safe_division(total_spent, this_year_purchases_without_refunded_count) | ||||
|         ), | ||||
|         "all_finished_this_year": purchases_finished_this_year.prefetch_related( | ||||
|             "editions" | ||||
|             "games" | ||||
|         ).order_by("date_finished"), | ||||
|         "all_finished_this_year_count": purchases_finished_this_year.count(), | ||||
|         "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related( | ||||
|             "editions" | ||||
|             "games" | ||||
|         ).order_by("date_finished"), | ||||
|         "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), | ||||
|         "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related( | ||||
|             "editions" | ||||
|             "games" | ||||
|         ).order_by("date_finished"), | ||||
|         "total_sessions": this_year_sessions.count(), | ||||
|         "unique_days": unique_days["dates"], | ||||
| @ -472,9 +475,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|             if longest_session | ||||
|             else 0 | ||||
|         ), | ||||
|         "longest_session_game": ( | ||||
|             longest_session.purchase.first_edition.game if longest_session else None | ||||
|         ), | ||||
|         "longest_session_game": (longest_session.game if longest_session else None), | ||||
|         "highest_session_count": ( | ||||
|             game_highest_session_count.session_count | ||||
|             if game_highest_session_count | ||||
|  | ||||
| @ -16,7 +16,7 @@ from django.utils import timezone | ||||
| from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice | ||||
| from common.time import dateformat | ||||
| from games.forms import PurchaseForm | ||||
| from games.models import Edition, Purchase | ||||
| from games.models import Game, Purchase | ||||
| from games.views.general import use_custom_redirect | ||||
|  | ||||
|  | ||||
| @ -138,7 +138,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse: | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse: | ||||
| def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse: | ||||
|     context: dict[str, Any] = {} | ||||
|     initial = {"date_purchased": timezone.now()} | ||||
|  | ||||
| @ -149,19 +149,20 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse: | ||||
|             if "submit_and_redirect" in request.POST: | ||||
|                 return HttpResponseRedirect( | ||||
|                     reverse( | ||||
|                         "add_session_for_purchase", kwargs={"purchase_id": purchase.id} | ||||
|                         "add_session_for_game", | ||||
|                         kwargs={"game_id": purchase.first_game.id}, | ||||
|                     ) | ||||
|                 ) | ||||
|             else: | ||||
|                 return redirect("list_purchases") | ||||
|     else: | ||||
|         if edition_id: | ||||
|             edition = Edition.objects.get(id=edition_id) | ||||
|         if game_id: | ||||
|             game = Game.objects.get(id=game_id) | ||||
|             form = PurchaseForm( | ||||
|                 initial={ | ||||
|                     **initial, | ||||
|                     "edition": edition, | ||||
|                     "platform": edition.platform, | ||||
|                     "games": [game], | ||||
|                     "platform": game.platform, | ||||
|                 } | ||||
|             ) | ||||
|         else: | ||||
| @ -199,7 +200,11 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: | ||||
| @login_required | ||||
| def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: | ||||
|     purchase = get_object_or_404(Purchase, id=purchase_id) | ||||
|     return render(request, "view_purchase.html", {"purchase": purchase}) | ||||
|     return render( | ||||
|         request, | ||||
|         "view_purchase.html", | ||||
|         {"purchase": purchase, "title": f"Purchase: {purchase.full_name}"}, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @ -226,12 +231,14 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: | ||||
|     return redirect("list_purchases") | ||||
|  | ||||
|  | ||||
| def related_purchase_by_edition(request: HttpRequest) -> HttpResponse: | ||||
|     edition_id = request.GET.get("edition") | ||||
|     if not edition_id: | ||||
|         return HttpResponseBadRequest("Invalid edition_id") | ||||
| def related_purchase_by_game(request: HttpRequest) -> HttpResponse: | ||||
|     games = request.GET.getlist("games") | ||||
|     if not games: | ||||
|         return HttpResponseBadRequest("Invalid game_id") | ||||
|     if isinstance(games, int) or isinstance(games, str): | ||||
|         games = [games] | ||||
|     form = PurchaseForm() | ||||
|     form.fields["related_purchase"].queryset = Purchase.objects.filter( | ||||
|         edition_id=edition_id, type=Purchase.GAME | ||||
|     ).order_by("edition__sort_name") | ||||
|         games__in=games, type=Purchase.GAME | ||||
|     ).order_by("games__sort_name") | ||||
|     return render(request, "partials/related_purchase_field.html", {"form": form}) | ||||
|  | ||||
| @ -15,7 +15,7 @@ from common.components import ( | ||||
|     Div, | ||||
|     Form, | ||||
|     Icon, | ||||
|     LinkedNameWithPlatformIcon, | ||||
|     NameWithIcon, | ||||
|     Popover, | ||||
| ) | ||||
| from common.time import ( | ||||
| @ -28,7 +28,7 @@ from common.time import ( | ||||
| ) | ||||
| from common.utils import truncate | ||||
| from games.forms import SessionForm | ||||
| from games.models import Purchase, Session | ||||
| from games.models import Game, Session | ||||
| from games.views.general import use_custom_redirect | ||||
|  | ||||
|  | ||||
| @ -37,13 +37,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse | ||||
|     context: dict[Any, Any] = {} | ||||
|     page_number = request.GET.get("page", 1) | ||||
|     limit = request.GET.get("limit", 10) | ||||
|     sessions = Session.objects.order_by("-timestamp_start") | ||||
|     sessions = Session.objects.order_by("-timestamp_start", "created_at") | ||||
|     search_string = request.GET.get("search_string", search_string) | ||||
|     if search_string != "": | ||||
|         sessions = sessions.filter( | ||||
|             Q(purchase__edition__name__icontains=search_string) | ||||
|             | Q(purchase__edition__game__name__icontains=search_string) | ||||
|             | Q(purchase__platform__name__icontains=search_string) | ||||
|             Q(game__name__icontains=search_string) | ||||
|             | Q(game__name__icontains=search_string) | ||||
|             | Q(game__platform__name__icontains=search_string) | ||||
|             | Q(device__name__icontains=search_string) | ||||
|             | Q(device__type__icontains=search_string) | ||||
|         ) | ||||
| @ -97,7 +97,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse | ||||
|                                     args=[last_session.pk], | ||||
|                                 ), | ||||
|                                 children=Popover( | ||||
|                                     popover_content=last_session.purchase.first_edition.name, | ||||
|                                     popover_content=last_session.game.name, | ||||
|                                     children=[ | ||||
|                                         Button( | ||||
|                                             icon=True, | ||||
| @ -105,9 +105,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse | ||||
|                                             size="xs", | ||||
|                                             children=[ | ||||
|                                                 Icon("play"), | ||||
|                                                 truncate( | ||||
|                                                     f"{last_session.purchase.first_edition.name}" | ||||
|                                                 ), | ||||
|                                                 truncate(f"{last_session.game.name}"), | ||||
|                                             ], | ||||
|                                         ) | ||||
|                                     ], | ||||
| @ -130,12 +128,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     LinkedNameWithPlatformIcon( | ||||
|                         name=session.purchase.first_edition.name, | ||||
|                         game_id=session.purchase.first_edition.game.pk, | ||||
|                         platform=session.purchase.platform, | ||||
|                     ), | ||||
|                     f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", | ||||
|                     NameWithIcon(session_id=session.pk), | ||||
|                     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 | ||||
| @ -195,13 +189,13 @@ def search_sessions(request: HttpRequest) -> HttpResponse: | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse: | ||||
| def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse: | ||||
|     context = {} | ||||
|     initial: dict[str, Any] = {"timestamp_start": timezone.now()} | ||||
|  | ||||
|     last = Session.objects.last() | ||||
|     if last != None: | ||||
|         initial["purchase"] = last.purchase | ||||
|         initial["game"] = last.game | ||||
|  | ||||
|     if request.method == "POST": | ||||
|         form = SessionForm(request.POST or None, initial=initial) | ||||
| @ -209,21 +203,22 @@ def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse: | ||||
|             form.save() | ||||
|             return redirect("list_sessions") | ||||
|     else: | ||||
|         if purchase_id: | ||||
|             purchase = Purchase.objects.get(id=purchase_id) | ||||
|         if game_id: | ||||
|             game = Game.objects.get(id=game_id) | ||||
|             form = SessionForm( | ||||
|                 initial={ | ||||
|                     **initial, | ||||
|                     "purchase": purchase, | ||||
|                     "game": game, | ||||
|                 } | ||||
|             ) | ||||
|         else: | ||||
|             form = SessionForm(initial=initial) | ||||
|  | ||||
|     context["title"] = "Add New Session" | ||||
|     context["script_name"] = "add_session.js" | ||||
|     # TODO: re-add custom buttons #91 | ||||
|     # context["script_name"] = "add_session.js" | ||||
|     context["form"] = form | ||||
|     return render(request, "add_session.html", context) | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @ -237,7 +232,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse: | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Session" | ||||
|     context["form"] = form | ||||
|     return render(request, "add_session.html", context) | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| def clone_session_by_id(session_id: int) -> Session: | ||||
|  | ||||
							
								
								
									
										170
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										170
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| # This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. | ||||
| # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. | ||||
|  | ||||
| [[package]] | ||||
| name = "asgiref" | ||||
| @ -6,6 +6,7 @@ version = "3.8.1" | ||||
| description = "ASGI specs, helper code, and adapters" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, | ||||
|     {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, | ||||
| @ -20,6 +21,7 @@ version = "2024.8.30" | ||||
| description = "Python package for providing Mozilla's CA Bundle." | ||||
| optional = false | ||||
| python-versions = ">=3.6" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, | ||||
|     {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, | ||||
| @ -31,6 +33,7 @@ version = "3.4.0" | ||||
| description = "Validate configuration and produce human readable error messages." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, | ||||
|     {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, | ||||
| @ -42,6 +45,7 @@ version = "3.4.0" | ||||
| description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." | ||||
| optional = false | ||||
| python-versions = ">=3.7.0" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, | ||||
|     {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, | ||||
| @ -156,6 +160,7 @@ version = "8.1.7" | ||||
| description = "Composable command line interface toolkit" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, | ||||
|     {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, | ||||
| @ -170,10 +175,12 @@ version = "0.4.6" | ||||
| description = "Cross-platform colored terminal text." | ||||
| optional = false | ||||
| python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, | ||||
|     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, | ||||
| ] | ||||
| markers = {main = "platform_system == \"Windows\""} | ||||
|  | ||||
| [[package]] | ||||
| name = "croniter" | ||||
| @ -181,6 +188,7 @@ version = "5.0.1" | ||||
| description = "croniter provides iteration for datetime object with cron like format" | ||||
| optional = false | ||||
| python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"}, | ||||
|     {file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"}, | ||||
| @ -196,6 +204,7 @@ version = "1.15.1" | ||||
| description = "CSS unobfuscator and beautifier." | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"}, | ||||
| ] | ||||
| @ -211,6 +220,7 @@ version = "0.3.9" | ||||
| description = "Distribution utilities" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, | ||||
|     {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, | ||||
| @ -218,13 +228,14 @@ files = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "django" | ||||
| version = "5.1.3" | ||||
| version = "5.1.7" | ||||
| description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." | ||||
| optional = false | ||||
| python-versions = ">=3.10" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "Django-5.1.3-py3-none-any.whl", hash = "sha256:8b38a9a12da3ae00cb0ba72da985ec4b14de6345046b1e174b1fd7254398f818"}, | ||||
|     {file = "Django-5.1.3.tar.gz", hash = "sha256:c0fa0e619c39325a169208caef234f90baa925227032ad3f44842ba14d75234a"}, | ||||
|     {file = "Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b"}, | ||||
|     {file = "Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -238,13 +249,14 @@ bcrypt = ["bcrypt"] | ||||
|  | ||||
| [[package]] | ||||
| name = "django-cotton" | ||||
| version = "1.3.0" | ||||
| version = "1.6.0" | ||||
| description = "Bringing component based design to Django templates." | ||||
| optional = false | ||||
| python-versions = "<4,>=3.8" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "django_cotton-1.3.0-py3-none-any.whl", hash = "sha256:a23f29b759c43423e2f901352c0810388839cc412f6985614153c6ccfcfc2595"}, | ||||
|     {file = "django_cotton-1.3.0.tar.gz", hash = "sha256:8f4a15dd55c8ee9182cf7234c228ea45d9fcdec1de125221bce8d05af035730a"}, | ||||
|     {file = "django_cotton-1.6.0-py3-none-any.whl", hash = "sha256:46452e5fc9ddfff43ac3b10925ba63151e2e9143ffa665a9519178122204b456"}, | ||||
|     {file = "django_cotton-1.6.0.tar.gz", hash = "sha256:1feb2ab486491f304e701fda82f37e608f0b9874473b3ec92922f3891d1a6cd7"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -256,6 +268,7 @@ version = "4.4.6" | ||||
| description = "A configurable set of panels that display various debug information about the current request/response." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45"}, | ||||
|     {file = "django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044"}, | ||||
| @ -271,6 +284,7 @@ version = "3.2.3" | ||||
| description = "Extensions for Django" | ||||
| optional = false | ||||
| python-versions = ">=3.6" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"}, | ||||
|     {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"}, | ||||
| @ -285,6 +299,7 @@ version = "1.21.0" | ||||
| description = "Extensions for using Django with htmx." | ||||
| optional = false | ||||
| python-versions = ">=3.9" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:64bc31463017a80552b767bc216ee5700248fa72e7ccd2963495e69afbdb6abe"}, | ||||
|     {file = "django_htmx-1.21.0.tar.gz", hash = "sha256:6ed3b42effd5980f22e68f36cd14ee4311bff3b6cb8435a89e27f45995691572"}, | ||||
| @ -300,6 +315,7 @@ version = "3.2" | ||||
| description = "Pickled object field for Django" | ||||
| optional = false | ||||
| python-versions = ">=3" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "django-picklefield-3.2.tar.gz", hash = "sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d"}, | ||||
|     {file = "django_picklefield-3.2-py3-none-any.whl", hash = "sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c"}, | ||||
| @ -317,6 +333,7 @@ version = "1.7.4" | ||||
| description = "A multiprocessing distributed task queue for Django" | ||||
| optional = false | ||||
| python-versions = "<4,>=3.8" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "django_q2-1.7.4-py3-none-any.whl", hash = "sha256:6eda6d56505822ee5ebc6c4eac1dde726f5dbf20ee9ea7080575535852e2671f"}, | ||||
|     {file = "django_q2-1.7.4.tar.gz", hash = "sha256:56a3781cc480474fa9c04bbde62445b0a9b4195adc409bd963b8f593b0598c43"}, | ||||
| @ -337,6 +354,7 @@ version = "24.4" | ||||
| description = "django-template-partials" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "django_template_partials-24.4-py2.py3-none-any.whl", hash = "sha256:ee59d3839385d7f648907c3fa8d5923fcd66cd8090f141fe2a1c338b917984e2"}, | ||||
|     {file = "django_template_partials-24.4.tar.gz", hash = "sha256:25b67301470fc274ecc419e5e5fd4686a5020b1c038fd241a70eb087809034b6"}, | ||||
| @ -355,6 +373,7 @@ version = "3.0.7" | ||||
| description = "Django/Jinja template indenter" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "djhtml-3.0.7.tar.gz", hash = "sha256:558c905b092a0c8afcbed27dea2f50aa6eb853a658b309e4e0f2bb378bdf6178"}, | ||||
| ] | ||||
| @ -368,6 +387,7 @@ version = "1.36.1" | ||||
| description = "HTML Template Linter and Formatter" | ||||
| optional = false | ||||
| python-versions = ">=3.9" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "djlint-1.36.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef40527fd6cd82cdd18f65a6bf5b486b767d2386f6c21f2ebd60e5d88f487fe8"}, | ||||
|     {file = "djlint-1.36.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4712de3dea172000a098da6a0cd709d158909b4964ba0f68bee584cef18b4878"}, | ||||
| @ -410,6 +430,7 @@ version = "0.12.4" | ||||
| description = "EditorConfig File Locator and Interpreter for Python" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, | ||||
| ] | ||||
| @ -420,6 +441,7 @@ version = "3.16.1" | ||||
| description = "A platform independent file lock." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, | ||||
|     {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, | ||||
| @ -428,7 +450,7 @@ files = [ | ||||
| [package.extras] | ||||
| docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] | ||||
| testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] | ||||
| typing = ["typing-extensions (>=4.12.2)"] | ||||
| typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] | ||||
|  | ||||
| [[package]] | ||||
| name = "graphene" | ||||
| @ -436,6 +458,7 @@ version = "3.4.3" | ||||
| description = "GraphQL Framework for Python" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"}, | ||||
|     {file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"}, | ||||
| @ -457,6 +480,7 @@ version = "3.2.2" | ||||
| description = "Graphene Django integration" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "graphene-django-3.2.2.tar.gz", hash = "sha256:059ccf25d9a5159f28d7ebf1a648c993ab34deb064e80b70ca096aa22a609556"}, | ||||
|     {file = "graphene_django-3.2.2-py2.py3-none-any.whl", hash = "sha256:0fd95c8c1cbe77ae2a5940045ce276803c3acbf200a156731e0c730f2776ae2c"}, | ||||
| @ -481,6 +505,7 @@ version = "3.2.5" | ||||
| description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." | ||||
| optional = false | ||||
| python-versions = "<4,>=3.6" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a"}, | ||||
|     {file = "graphql_core-3.2.5.tar.gz", hash = "sha256:e671b90ed653c808715645e3998b7ab67d382d55467b7e2978549111bbabf8d5"}, | ||||
| @ -492,6 +517,7 @@ version = "3.2.0" | ||||
| description = "Relay library for graphql-core" | ||||
| optional = false | ||||
| python-versions = ">=3.6,<4" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"}, | ||||
|     {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"}, | ||||
| @ -502,13 +528,14 @@ graphql-core = ">=3.2,<3.3" | ||||
|  | ||||
| [[package]] | ||||
| name = "gunicorn" | ||||
| version = "22.0.0" | ||||
| version = "23.0.0" | ||||
| description = "WSGI HTTP Server for UNIX" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, | ||||
|     {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, | ||||
|     {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, | ||||
|     {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -527,6 +554,7 @@ version = "0.14.0" | ||||
| description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, | ||||
|     {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, | ||||
| @ -538,6 +566,7 @@ version = "2.6.2" | ||||
| description = "File identification library for Python" | ||||
| optional = false | ||||
| python-versions = ">=3.9" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"}, | ||||
|     {file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"}, | ||||
| @ -552,6 +581,7 @@ version = "3.10" | ||||
| description = "Internationalized Domain Names in Applications (IDNA)" | ||||
| optional = false | ||||
| python-versions = ">=3.6" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, | ||||
|     {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, | ||||
| @ -566,6 +596,7 @@ version = "2.0.0" | ||||
| description = "brain-dead simple config-ini parsing" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, | ||||
|     {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, | ||||
| @ -577,6 +608,7 @@ version = "5.13.2" | ||||
| description = "A Python utility / library to sort Python imports." | ||||
| optional = false | ||||
| python-versions = ">=3.8.0" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, | ||||
|     {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, | ||||
| @ -591,6 +623,7 @@ version = "1.15.1" | ||||
| description = "JavaScript unobfuscator and beautifier." | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, | ||||
| ] | ||||
| @ -605,6 +638,7 @@ version = "0.9.25" | ||||
| description = "A Python implementation of the JSON5 data format." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"}, | ||||
|     {file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"}, | ||||
| @ -616,6 +650,7 @@ version = "3.7" | ||||
| description = "Python implementation of John Gruber's Markdown." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, | ||||
|     {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, | ||||
| @ -627,48 +662,49 @@ testing = ["coverage", "pyyaml"] | ||||
|  | ||||
| [[package]] | ||||
| name = "mypy" | ||||
| version = "1.13.0" | ||||
| version = "1.15.0" | ||||
| description = "Optional static typing for Python" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| python-versions = ">=3.9" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, | ||||
|     {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, | ||||
|     {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, | ||||
|     {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, | ||||
|     {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, | ||||
|     {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, | ||||
|     {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, | ||||
|     {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, | ||||
|     {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, | ||||
|     {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, | ||||
|     {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, | ||||
|     {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, | ||||
|     {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, | ||||
|     {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, | ||||
|     {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, | ||||
|     {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, | ||||
|     {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, | ||||
|     {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, | ||||
|     {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, | ||||
|     {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, | ||||
|     {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, | ||||
|     {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, | ||||
|     {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, | ||||
|     {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, | ||||
|     {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, | ||||
|     {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, | ||||
|     {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, | ||||
|     {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, | ||||
|     {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, | ||||
|     {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, | ||||
|     {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, | ||||
|     {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, | ||||
|     {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, | ||||
|     {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, | ||||
|     {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, | ||||
|     {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, | ||||
|     {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, | ||||
|     {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, | ||||
|     {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, | ||||
|     {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, | ||||
|     {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, | ||||
|     {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, | ||||
|     {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, | ||||
|     {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, | ||||
|     {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, | ||||
|     {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, | ||||
|     {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, | ||||
|     {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, | ||||
|     {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, | ||||
|     {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, | ||||
|     {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, | ||||
|     {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, | ||||
|     {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, | ||||
|     {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, | ||||
|     {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, | ||||
|     {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, | ||||
|     {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, | ||||
|     {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, | ||||
|     {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, | ||||
|     {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, | ||||
|     {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, | ||||
|     {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, | ||||
|     {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, | ||||
|     {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| mypy-extensions = ">=1.0.0" | ||||
| typing-extensions = ">=4.6.0" | ||||
| mypy_extensions = ">=1.0.0" | ||||
| typing_extensions = ">=4.6.0" | ||||
|  | ||||
| [package.extras] | ||||
| dmypy = ["psutil (>=4.0)"] | ||||
| @ -683,6 +719,7 @@ version = "1.0.0" | ||||
| description = "Type system extensions for programs checked with the mypy type checker." | ||||
| optional = false | ||||
| python-versions = ">=3.5" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, | ||||
|     {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, | ||||
| @ -694,6 +731,7 @@ version = "1.9.1" | ||||
| description = "Node.js virtual environment builder" | ||||
| optional = false | ||||
| python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, | ||||
|     {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, | ||||
| @ -705,6 +743,7 @@ version = "24.2" | ||||
| description = "Core utilities for Python packages" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, | ||||
|     {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, | ||||
| @ -716,6 +755,7 @@ version = "0.12.1" | ||||
| description = "Utility library for gitignore style pattern matching of file paths." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, | ||||
|     {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, | ||||
| @ -727,6 +767,7 @@ version = "4.3.6" | ||||
| description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, | ||||
|     {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, | ||||
| @ -743,6 +784,7 @@ version = "1.5.0" | ||||
| description = "plugin and hook calling mechanisms for python" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, | ||||
|     {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, | ||||
| @ -758,6 +800,7 @@ version = "3.8.0" | ||||
| description = "A framework for managing and maintaining multi-language pre-commit hooks." | ||||
| optional = false | ||||
| python-versions = ">=3.9" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, | ||||
|     {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, | ||||
| @ -776,6 +819,7 @@ version = "2.3" | ||||
| description = "Promises/A+ implementation for Python" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"}, | ||||
| ] | ||||
| @ -792,6 +836,7 @@ version = "8.3.3" | ||||
| description = "pytest: simple powerful testing with Python" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, | ||||
|     {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, | ||||
| @ -812,6 +857,7 @@ version = "2.9.0.post0" | ||||
| description = "Extensions to the standard Python datetime module" | ||||
| optional = false | ||||
| python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, | ||||
|     {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, | ||||
| @ -826,6 +872,7 @@ version = "2024.2" | ||||
| description = "World timezone definitions, modern and historical" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, | ||||
|     {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, | ||||
| @ -837,6 +884,7 @@ version = "6.0.2" | ||||
| description = "YAML parser and emitter for Python" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, | ||||
| @ -899,6 +947,7 @@ version = "2024.11.6" | ||||
| description = "Alternative regular expression module, to replace re." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, | ||||
|     {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, | ||||
| @ -1002,6 +1051,7 @@ version = "2.32.3" | ||||
| description = "Python HTTP for Humans." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, | ||||
|     {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, | ||||
| @ -1023,6 +1073,7 @@ version = "1.16.0" | ||||
| description = "Python 2 and 3 compatibility utilities" | ||||
| optional = false | ||||
| python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, | ||||
|     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, | ||||
| @ -1034,6 +1085,7 @@ version = "0.5.1" | ||||
| description = "A non-validating SQL parser." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, | ||||
|     {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, | ||||
| @ -1049,6 +1101,7 @@ version = "1.3" | ||||
| description = "The most basic Text::Unidecode port" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, | ||||
|     {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, | ||||
| @ -1060,6 +1113,7 @@ version = "4.67.0" | ||||
| description = "Fast, Extensible Progress Meter" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be"}, | ||||
|     {file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"}, | ||||
| @ -1081,6 +1135,7 @@ version = "4.12.2" | ||||
| description = "Backported and Experimental Type Hints for Python 3.8+" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main", "dev"] | ||||
| files = [ | ||||
|     {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, | ||||
|     {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, | ||||
| @ -1092,6 +1147,8 @@ version = "2024.2" | ||||
| description = "Provider of IANA time zone data" | ||||
| optional = false | ||||
| python-versions = ">=2" | ||||
| groups = ["main", "dev"] | ||||
| markers = "sys_platform == \"win32\"" | ||||
| files = [ | ||||
|     {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, | ||||
|     {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, | ||||
| @ -1103,13 +1160,14 @@ version = "2.2.3" | ||||
| description = "HTTP library with thread-safe connection pooling, file post, and more." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, | ||||
|     {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, | ||||
| ] | ||||
|  | ||||
| [package.extras] | ||||
| brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] | ||||
| brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] | ||||
| h2 = ["h2 (>=4,<5)"] | ||||
| socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] | ||||
| zstd = ["zstandard (>=0.18.0)"] | ||||
| @ -1120,6 +1178,7 @@ version = "0.30.6" | ||||
| description = "The lightning-fast ASGI server." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["main"] | ||||
| files = [ | ||||
|     {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, | ||||
|     {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, | ||||
| @ -1130,17 +1189,18 @@ click = ">=7.0" | ||||
| h11 = ">=0.8" | ||||
|  | ||||
| [package.extras] | ||||
| standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] | ||||
| standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] | ||||
|  | ||||
| [[package]] | ||||
| name = "virtualenv" | ||||
| version = "20.27.1" | ||||
| version = "20.29.1" | ||||
| description = "Virtual Python Environment builder" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| groups = ["dev"] | ||||
| files = [ | ||||
|     {file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"}, | ||||
|     {file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"}, | ||||
|     {file = "virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779"}, | ||||
|     {file = "virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -1150,9 +1210,9 @@ platformdirs = ">=3.9.1,<5" | ||||
|  | ||||
| [package.extras] | ||||
| docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] | ||||
| test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] | ||||
| test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] | ||||
|  | ||||
| [metadata] | ||||
| lock-version = "2.0" | ||||
| lock-version = "2.1" | ||||
| python-versions = "^3.11" | ||||
| content-hash = "b5bb46a6591964aec145637cd9a412a681f2cc5e7e4fdd6fd9ecb0fe8724b8e3" | ||||
| content-hash = "3a1c1cd04ceca7a53961a487d4e2659c53384d59a5d524e3548b0f0b3c4bbc57" | ||||
|  | ||||
| @ -22,7 +22,7 @@ django-debug-toolbar = "^4.4.2" | ||||
| [tool.poetry.dependencies] | ||||
| python = "^3.11" | ||||
| django = "^5.0.6" | ||||
| gunicorn = "^22.0.0" | ||||
| gunicorn = "^23.0.0" | ||||
| uvicorn = "^0.30.1" | ||||
| graphene-django = "^3.2.0" | ||||
| django-htmx = "^1.18.0" | ||||
|  | ||||
| @ -10,7 +10,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") | ||||
| django.setup() | ||||
| from django.conf import settings | ||||
|  | ||||
| from games.models import Edition, Game, Platform, Purchase, Session | ||||
| from games.models import Game, Platform, Purchase, Session | ||||
|  | ||||
| ZONEINFO = ZoneInfo(settings.TIME_ZONE) | ||||
|  | ||||
| @ -21,10 +21,8 @@ class PathWorksTest(TestCase): | ||||
|         pl.save() | ||||
|         g = Game(name="The Test Game") | ||||
|         g.save() | ||||
|         e = Edition(game=g, name="The Test Game Edition", platform=pl) | ||||
|         e.save() | ||||
|         p = Purchase( | ||||
|             edition=e, | ||||
|             games=[e], | ||||
|             platform=pl, | ||||
|             date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO), | ||||
|         ) | ||||
| @ -53,11 +51,6 @@ class PathWorksTest(TestCase): | ||||
|         response = self.client.get(url) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_add_edition_returns_200(self): | ||||
|         url = reverse("add_edition") | ||||
|         response = self.client.get(url) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_add_purchase_returns_200(self): | ||||
|         url = reverse("add_purchase") | ||||
|         response = self.client.get(url) | ||||
|  | ||||
| @ -3,14 +3,13 @@ from datetime import datetime | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| import django | ||||
| from django.db import models | ||||
| from django.test import TestCase | ||||
|  | ||||
| os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") | ||||
| django.setup() | ||||
| from django.conf import settings | ||||
|  | ||||
| from games.models import Edition, Game, Purchase, Session | ||||
| from games.models import Game, Purchase, Session | ||||
|  | ||||
| ZONEINFO = ZoneInfo(settings.TIME_ZONE) | ||||
|  | ||||
| @ -22,10 +21,8 @@ class FormatDurationTest(TestCase): | ||||
|     def test_duration_format(self): | ||||
|         g = Game(name="The Test Game") | ||||
|         g.save() | ||||
|         e = Edition(game=g, name="The Test Game Edition") | ||||
|         e.save() | ||||
|         p = Purchase( | ||||
|             edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO) | ||||
|             game=g, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO) | ||||
|         ) | ||||
|         p.save() | ||||
|         s = Session( | ||||
|  | ||||
| @ -47,7 +47,7 @@ INSTALLED_APPS = [ | ||||
|  | ||||
| Q_CLUSTER = { | ||||
|     "name": "DjangoQ", | ||||
|     "workers": 4, | ||||
|     "workers": 1, | ||||
|     "recycle": 500, | ||||
|     "timeout": 60, | ||||
|     "retry": 120, | ||||
| @ -113,6 +113,10 @@ DATABASES = { | ||||
|     "default": { | ||||
|         "ENGINE": "django.db.backends.sqlite3", | ||||
|         "NAME": BASE_DIR / "db.sqlite3", | ||||
|         "OPTIONS": { | ||||
|             "timeout": 20, | ||||
|             "init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;", | ||||
|         }, | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
		Reference in New Issue
	
	Block a user