Compare commits
	
		
			3 Commits
		
	
	
		
			c8a3212b77
			...
			f31280c682
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f31280c682 | |||
| a745d16ec3 | |||
| ae079e36ec | 
							
								
								
									
										10
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @ -1,7 +1,15 @@ | |||||||
| ### Unreleased | ## 1.5.0 / 2023-11-14 19:27+01:00 | ||||||
|  |  | ||||||
| ## New | ## New | ||||||
| * Add stat for finished this year's games | * Add stat for finished this year's games | ||||||
|  | * Add purchase types: | ||||||
|  |   * Game (previously all of them were this type) | ||||||
|  |   * DLC | ||||||
|  |   * Season Pass | ||||||
|  |   * Battle Pass | ||||||
|  |  | ||||||
|  | ## Fixed | ||||||
|  | * Order purchases by date on game view | ||||||
|  |  | ||||||
| ## 1.4.0 / 2023-11-09 21:01+01:00 | ## 1.4.0 / 2023-11-09 21:01+01:00 | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ RUN npm install && \ | |||||||
|  |  | ||||||
| FROM python:3.10.9-slim-bullseye | FROM python:3.10.9-slim-bullseye | ||||||
|  |  | ||||||
| ENV VERSION_NUMBER 1.4.0 | ENV VERSION_NUMBER 1.5.0 | ||||||
| ENV PROD 1 | ENV PROD 1 | ||||||
| ENV PYTHONUNBUFFERED=1 | ENV PYTHONUNBUFFERED=1 | ||||||
|  |  | ||||||
|  | |||||||
| @ -66,6 +66,12 @@ textarea { | |||||||
|   @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; |   @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) { | @media screen and (min-width: 768px) { | ||||||
|   form input, |   form input, | ||||||
|   select, |   select, | ||||||
|  | |||||||
| @ -55,6 +55,9 @@ class PurchaseForm(forms.ModelForm): | |||||||
|         widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), |         widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), | ||||||
|     ) |     ) | ||||||
|     platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) |     platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) | ||||||
|  |     related_purchase = forms.ModelChoiceField( | ||||||
|  |         queryset=Purchase.objects.order_by("edition__sort_name") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         widgets = { |         widgets = { | ||||||
| @ -72,6 +75,9 @@ class PurchaseForm(forms.ModelForm): | |||||||
|             "price", |             "price", | ||||||
|             "price_currency", |             "price_currency", | ||||||
|             "ownership_type", |             "ownership_type", | ||||||
|  |             "type", | ||||||
|  |             "related_purchase", | ||||||
|  |             "name", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								games/migrations/0026_purchase_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								games/migrations/0026_purchase_type.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-14 08:35 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("games", "0025_game_sort_name"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="purchase", | ||||||
|  |             name="type", | ||||||
|  |             field=models.CharField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("game", "Game"), | ||||||
|  |                     ("dlc", "DLC"), | ||||||
|  |                     ("season_pass", "Season Pass"), | ||||||
|  |                     ("battle_pass", "Battle Pass"), | ||||||
|  |                 ], | ||||||
|  |                 default="game", | ||||||
|  |                 max_length=255, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										25
									
								
								games/migrations/0027_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								games/migrations/0027_purchase_related_purchase.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-11-14 08:41 | ||||||
|  |  | ||||||
|  | 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", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										25
									
								
								games/migrations/0028_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								games/migrations/0028_purchase_name.py
									
									
									
									
									
										Normal file
									
								
							| @ -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), | ||||||
|  |     ] | ||||||
| @ -71,6 +71,9 @@ class PurchaseQueryset(models.QuerySet): | |||||||
|     def finished(self): |     def finished(self): | ||||||
|         return self.filter(date_finished__isnull=False) |         return self.filter(date_finished__isnull=False) | ||||||
|  |  | ||||||
|  |     def games_only(self): | ||||||
|  |         return self.filter(type=Purchase.GAME) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Purchase(models.Model): | class Purchase(models.Model): | ||||||
|     PHYSICAL = "ph" |     PHYSICAL = "ph" | ||||||
| @ -91,6 +94,16 @@ class Purchase(models.Model): | |||||||
|         (DEMO, "Demo"), |         (DEMO, "Demo"), | ||||||
|         (PIRATED, "Pirated"), |         (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() |     objects = PurchaseQueryset().as_manager() | ||||||
|  |  | ||||||
| @ -106,6 +119,13 @@ class Purchase(models.Model): | |||||||
|     ownership_type = models.CharField( |     ownership_type = models.CharField( | ||||||
|         max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL |         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): |     def __str__(self): | ||||||
|         platform_info = self.platform |         platform_info = self.platform | ||||||
| @ -113,6 +133,14 @@ class Purchase(models.Model): | |||||||
|             platform_info = f"{self.edition.platform} version on {self.platform}" |             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()})" |         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): | class Platform(models.Model): | ||||||
|     name = models.CharField(max_length=255) |     name = models.CharField(max_length=255) | ||||||
|  | |||||||
| @ -1222,6 +1222,15 @@ textarea) { | |||||||
|   color: rgb(241 245 249 / var(--tw-text-opacity)); |   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) { | @media screen and (min-width: 768px) { | ||||||
|   form input, |   form input, | ||||||
|   select, |   select, | ||||||
| @ -1428,6 +1437,10 @@ th label { | |||||||
|     padding-left: 1rem; |     padding-left: 1rem; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .sm\:pl-6 { | ||||||
|  |     padding-left: 1.5rem; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   .sm\:decoration-2 { |   .sm\:decoration-2 { | ||||||
|     text-decoration-thickness: 2px; |     text-decoration-thickness: 2px; | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -1,12 +1,31 @@ | |||||||
| import { syncSelectInputUntilChanged } from './utils.js' | import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js"; | ||||||
|  |  | ||||||
| let syncData = [ | let syncData = [ | ||||||
|   { |   { | ||||||
|     "source": "#id_edition", |     source: "#id_edition", | ||||||
|     "source_value": "dataset.platform", |     source_value: "dataset.platform", | ||||||
|     "target": "#id_platform", |     target: "#id_platform", | ||||||
|     "target_value": "value" |     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) | ||||||
|  | } | ||||||
|  | |||||||
| @ -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 }; | ||||||
|  | |||||||
| @ -181,7 +181,14 @@ | |||||||
|             <tbody> |             <tbody> | ||||||
|                 {% for purchase in all_purchased_this_year %} |                 {% for purchase in all_purchased_this_year %} | ||||||
|                 <tr> |                 <tr> | ||||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td> |                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||||
|  |                         <a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}"> | ||||||
|  |                             {{ purchase.edition.name }} | ||||||
|  |                         {% if purchase.type != "game" %} | ||||||
|  |                         ({{ purchase.name }}, {{ purchase.get_type_display }}) | ||||||
|  |                         {% endif %} | ||||||
|  |                         </a> | ||||||
|  |                     </td> | ||||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td> |                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td> | ||||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td> |                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td> | ||||||
|                 </tr> |                 </tr> | ||||||
|  | |||||||
| @ -42,6 +42,19 @@ | |||||||
|                 ({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}) |                 ({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}) | ||||||
|                 {% url 'edit_purchase' purchase.id as edit_url %} |                 {% url 'edit_purchase' purchase.id as edit_url %} | ||||||
|                 {% include 'components/edit_button.html' with edit_url=edit_url %} |                 {% include 'components/edit_button.html' with edit_url=edit_url %} | ||||||
|  |                 {% if purchase.related_purchases %} | ||||||
|  |                     <li> | ||||||
|  |                         <ul> | ||||||
|  |                             {% for related_purchase in purchase.related_purchases %} | ||||||
|  |                                 <li class="sm:pl-6 flex items-center"> | ||||||
|  |                                     {{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}}) | ||||||
|  |                                     {% url 'edit_purchase' related_purchase.id as edit_url %} | ||||||
|  |                                     {% include 'components/edit_button.html' with edit_url=edit_url %} | ||||||
|  |                                 </li> | ||||||
|  |                             {% endfor %} | ||||||
|  |                         </ul> | ||||||
|  |                     </li> | ||||||
|  |                 {% endif %} | ||||||
|             </li> |             </li> | ||||||
|             {% endfor %} |             {% endfor %} | ||||||
|         </ul> |         </ul> | ||||||
|  | |||||||
| @ -118,7 +118,8 @@ def edit_purchase(request, purchase_id=None): | |||||||
|         return redirect("list_sessions") |         return redirect("list_sessions") | ||||||
|     context["title"] = "Edit Purchase" |     context["title"] = "Edit Purchase" | ||||||
|     context["form"] = form |     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 | @use_custom_redirect | ||||||
| @ -140,7 +141,17 @@ def view_game(request, game_id=None): | |||||||
|     context["title"] = "View Game" |     context["title"] = "View Game" | ||||||
|     context["game"] = game |     context["game"] = game | ||||||
|     context["editions"] = Edition.objects.filter(game_id=game_id) |     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) | ||||||
|  |         .order_by("date_purchased") | ||||||
|  |     ) | ||||||
|  |     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( |     context["sessions"] = Session.objects.filter( | ||||||
|         purchase__edition__game_id=game_id |         purchase__edition__game_id=game_id | ||||||
|     ).order_by("-timestamp_start") |     ).order_by("-timestamp_start") | ||||||
| @ -312,7 +323,9 @@ def stats(request, year: int = 0): | |||||||
|  |  | ||||||
|     this_year_purchases_unfinished = this_year_purchases_without_refunded.filter( |     this_year_purchases_unfinished = this_year_purchases_without_refunded.filter( | ||||||
|         date_finished__isnull=True |         date_finished__isnull=True | ||||||
|     ) |     ).filter( | ||||||
|  |         type=Purchase.GAME | ||||||
|  |     )  # do not count DLC etc. | ||||||
|  |  | ||||||
|     this_year_purchases_unfinished_percent = int( |     this_year_purchases_unfinished_percent = int( | ||||||
|         safe_division( |         safe_division( | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "timetracker" | name = "timetracker" | ||||||
| version = "1.4.0" | version = "1.5.0" | ||||||
| description = "A simple time tracker." | description = "A simple time tracker." | ||||||
| authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] | authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] | ||||||
| license = "GPL" | license = "GPL" | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user