From 7ba212e71880850533af8153a0935103de2ba0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 14 Nov 2023 19:27:00 +0100 Subject: [PATCH] Add purchase types --- CHANGELOG.md | 5 ++ frontend/src/index.css | 6 +++ games/forms.py | 6 +++ games/migrations/0026_purchase_type.py | 27 +++++++++++ .../0027_purchase_related_purchase.py | 25 ++++++++++ games/migrations/0028_purchase_name.py | 25 ++++++++++ games/models.py | 28 +++++++++++ games/static/base.css | 13 +++++ games/static/js/add_purchase.js | 31 +++++++++--- games/static/js/utils.js | 47 ++++++++++++++++++- games/templates/stats.html | 9 +++- games/templates/view_game.html | 13 +++++ games/views.py | 17 +++++-- 13 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 games/migrations/0026_purchase_type.py create mode 100644 games/migrations/0027_purchase_related_purchase.py create mode 100644 games/migrations/0028_purchase_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7363f8b..a1335ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## New * Add stat for finished this year's games +* Add purchase types: + * Game (previously all of them were this type) + * DLC + * Season Pass + * Battle Pass ## 1.4.0 / 2023-11-09 21:01+01:00 diff --git a/frontend/src/index.css b/frontend/src/index.css index 3be0629..49b6857 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -66,6 +66,12 @@ 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; +} + @media screen and (min-width: 768px) { form input, select, diff --git a/games/forms.py b/games/forms.py index 8955284..08289d3 100644 --- a/games/forms.py +++ b/games/forms.py @@ -55,6 +55,9 @@ class PurchaseForm(forms.ModelForm): widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), ) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) + related_purchase = forms.ModelChoiceField( + queryset=Purchase.objects.order_by("edition__sort_name") + ) class Meta: widgets = { @@ -72,6 +75,9 @@ class PurchaseForm(forms.ModelForm): "price", "price_currency", "ownership_type", + "type", + "related_purchase", + "name", ] diff --git a/games/migrations/0026_purchase_type.py b/games/migrations/0026_purchase_type.py new file mode 100644 index 0000000..002054b --- /dev/null +++ b/games/migrations/0026_purchase_type.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.5 on 2023-11-14 08:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("games", "0025_game_sort_name"), + ] + + operations = [ + migrations.AddField( + model_name="purchase", + name="type", + field=models.CharField( + choices=[ + ("game", "Game"), + ("dlc", "DLC"), + ("season_pass", "Season Pass"), + ("battle_pass", "Battle Pass"), + ], + default="game", + max_length=255, + ), + ), + ] diff --git a/games/migrations/0027_purchase_related_purchase.py b/games/migrations/0027_purchase_related_purchase.py new file mode 100644 index 0000000..b5784f7 --- /dev/null +++ b/games/migrations/0027_purchase_related_purchase.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.5 on 2023-11-14 08:41 + +from django.db import migrations, models +import django.db.models.deletion + + +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", + ), + ), + ] diff --git a/games/migrations/0028_purchase_name.py b/games/migrations/0028_purchase_name.py new file mode 100644 index 0000000..7c08cfc --- /dev/null +++ b/games/migrations/0028_purchase_name.py @@ -0,0 +1,25 @@ +# 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), + ] diff --git a/games/models.py b/games/models.py index 620e0a0..461f106 100644 --- a/games/models.py +++ b/games/models.py @@ -71,6 +71,9 @@ class PurchaseQueryset(models.QuerySet): def finished(self): return self.filter(date_finished__isnull=False) + def games_only(self): + return self.filter(type=Purchase.GAME) + class Purchase(models.Model): PHYSICAL = "ph" @@ -91,6 +94,16 @@ class Purchase(models.Model): (DEMO, "Demo"), (PIRATED, "Pirated"), ] + GAME = "game" + DLC = "dlc" + SEASONPASS = "season_pass" + BATTLEPASS = "battle_pass" + TYPES = [ + (GAME, "Game"), + (DLC, "DLC"), + (SEASONPASS, "Season Pass"), + (BATTLEPASS, "Battle Pass"), + ] objects = PurchaseQueryset().as_manager() @@ -106,6 +119,13 @@ class Purchase(models.Model): 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="Unknown Name", null=True, blank=True + ) + related_purchase = models.ForeignKey( + "Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True + ) def __str__(self): platform_info = self.platform @@ -113,6 +133,14 @@ class Purchase(models.Model): platform_info = f"{self.edition.platform} version on {self.platform}" return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})" + def is_game(self): + return self.type == self.GAME + + def save(self, *args, **kwargs): + if self.type == Purchase.GAME: + self.name = "" + super().save(*args, **kwargs) + class Platform(models.Model): name = models.CharField(max_length=255) diff --git a/games/static/base.css b/games/static/base.css index 9790306..76ccf3e 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -1222,6 +1222,15 @@ textarea) { color: rgb(241 245 249 / var(--tw-text-opacity)); } +:is(.dark form input:disabled),:is(.dark +select:disabled),:is(.dark +textarea:disabled) { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(148 163 184 / var(--tw-text-opacity)); +} + @media screen and (min-width: 768px) { form input, select, @@ -1428,6 +1437,10 @@ th label { padding-left: 1rem; } + .sm\:pl-6 { + padding-left: 1.5rem; + } + .sm\:decoration-2 { text-decoration-thickness: 2px; } diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js index 40c93f5..c427652 100644 --- a/games/static/js/add_purchase.js +++ b/games/static/js/add_purchase.js @@ -1,12 +1,31 @@ -import { syncSelectInputUntilChanged } from './utils.js' +import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js"; let syncData = [ { - "source": "#id_edition", - "source_value": "dataset.platform", - "target": "#id_platform", - "target_value": "value" + source: "#id_edition", + source_value: "dataset.platform", + target: "#id_platform", + target_value: "value", + }, +]; + +syncSelectInputUntilChanged(syncData, "form"); + + +let myConfig = [ + () => { + return getEl("#id_type").value == "game"; + }, + ["#id_name", "#id_related_purchase"], + (el) => { + el.disabled = "disabled"; + }, + (el) => { + el.disabled = ""; } ] -syncSelectInputUntilChanged(syncData, "form") +document.DOMContentLoaded = conditionalElementHandler(...myConfig) +getEl("#id_type").onchange = () => { + conditionalElementHandler(...myConfig) +} diff --git a/games/static/js/utils.js b/games/static/js/utils.js index 3c45e4e..b625e68 100644 --- a/games/static/js/utils.js +++ b/games/static/js/utils.js @@ -87,4 +87,49 @@ function getValueFromProperty(sourceElement, property) { } } -export { toISOUTCString, syncSelectInputUntilChanged }; +/** + * @description Returns a single element by name. + * @param {string} selector The selector to look for. + */ +function getEl(selector) { + if (selector.startsWith("#")) { + return document.getElementById(selector.slice(1)) + } + else if (selector.startsWith(".")) { + return document.getElementsByClassName(selector) + } + else { + return document.getElementsByName(selector) + } +} + +/** + * @description Does something to elements when something happens. + * @param {() => boolean} condition The condition that is being tested. + * @param {string[]} targetElements + * @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches. + * @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match. + */ +function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) { + if (condition()) { + targetElements.forEach((elementName) => { + let el = getEl(elementName); + if (el === null) { + console.error("Element ${elementName} doesn't exist."); + } else { + callbackfn1(el); + } + }); + } else { + targetElements.forEach((elementName) => { + let el = getEl(elementName); + if (el === null) { + console.error("Element ${elementName} doesn't exist."); + } else { + callbackfn2(el); + } + }); + } +} + +export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler }; diff --git a/games/templates/stats.html b/games/templates/stats.html index 885cf8a..54efb71 100644 --- a/games/templates/stats.html +++ b/games/templates/stats.html @@ -181,7 +181,14 @@ {% for purchase in all_purchased_this_year %} - {{ purchase.edition.name }} + + + {{ purchase.edition.name }} + {% if purchase.type != "game" %} + ({{ purchase.name }}, {{ purchase.get_type_display }}) + {% endif %} + + {{ purchase.price }} {{ purchase.date_purchased | date:"d/m/Y" }} diff --git a/games/templates/view_game.html b/games/templates/view_game.html index 2576503..ecec827 100644 --- a/games/templates/view_game.html +++ b/games/templates/view_game.html @@ -42,6 +42,19 @@ ({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}) {% url 'edit_purchase' purchase.id as edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %} + {% if purchase.related_purchases %} +
  • + +
  • + {% endif %} {% endfor %} diff --git a/games/views.py b/games/views.py index 68247ee..b2bab74 100644 --- a/games/views.py +++ b/games/views.py @@ -118,7 +118,8 @@ def edit_purchase(request, purchase_id=None): return redirect("list_sessions") context["title"] = "Edit Purchase" context["form"] = form - return render(request, "add.html", context) + context["script_name"] = "add_purchase.js" + return render(request, "add_purchase.html", context) @use_custom_redirect @@ -140,7 +141,15 @@ def view_game(request, game_id=None): context["title"] = "View Game" context["game"] = game context["editions"] = Edition.objects.filter(game_id=game_id) - context["purchases"] = Purchase.objects.filter(edition__game_id=game_id) + game_purchases = Purchase.objects.filter(edition__game_id=game_id).filter( + type=Purchase.GAME + ) + for purchase in game_purchases: + purchase.related_purchases = Purchase.objects.exclude( + type=Purchase.GAME + ).filter(related_purchase=purchase.id) + + context["purchases"] = game_purchases context["sessions"] = Session.objects.filter( purchase__edition__game_id=game_id ).order_by("-timestamp_start") @@ -312,7 +321,9 @@ def stats(request, year: int = 0): this_year_purchases_unfinished = this_year_purchases_without_refunded.filter( date_finished__isnull=True - ) + ).filter( + type=Purchase.GAME + ) # do not count DLC etc. this_year_purchases_unfinished_percent = int( safe_division(