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 | ||||
| * 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 | ||||
|  | ||||
|  | ||||
| @ -6,7 +6,7 @@ RUN npm install && \ | ||||
|  | ||||
| FROM python:3.10.9-slim-bullseye | ||||
|  | ||||
| ENV VERSION_NUMBER 1.4.0 | ||||
| ENV VERSION_NUMBER 1.5.0 | ||||
| ENV PROD 1 | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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", | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										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): | ||||
|         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) | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
|  | ||||
| @ -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) | ||||
| } | ||||
|  | ||||
| @ -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> | ||||
|                 {% for purchase in all_purchased_this_year %} | ||||
|                 <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.date_purchased | date:"d/m/Y" }}</td> | ||||
|                 </tr> | ||||
|  | ||||
| @ -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 %} | ||||
|                     <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> | ||||
|             {% endfor %} | ||||
|         </ul> | ||||
|  | ||||
| @ -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,17 @@ 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) | ||||
|         .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( | ||||
|         purchase__edition__game_id=game_id | ||||
|     ).order_by("-timestamp_start") | ||||
| @ -312,7 +323,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( | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "timetracker" | ||||
| version = "1.4.0" | ||||
| version = "1.5.0" | ||||
| description = "A simple time tracker." | ||||
| authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] | ||||
| license = "GPL" | ||||
|  | ||||
		Reference in New Issue
	
	Block a user