Compare commits
	
		
			57 Commits
		
	
	
		
			86fd40cc4a
			...
			filters
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c2f1d8fe0a | |||
| cd3e400297 | |||
| c738245783 | |||
| 57184ceea0 | |||
| c2b9409562 | |||
| e067e65bce | |||
| b8258e2937 | |||
| 9af4c79947 | |||
| d8b8182b91 | |||
| 2fd44c1f53 | |||
| c3f99d124c | |||
| 51f5b9fceb | |||
| 973f4416de | |||
| a84209eb81 | |||
| 498cd69328 | |||
| b28c42d945 | |||
| 3099f02145 | |||
| 74b9d0421c | |||
| c61adad180 | |||
| 298ecb4092 | |||
| 020e12e20b | |||
| 6ef56bfed5 | |||
| fda4913c97 | |||
| e85b32e22f | |||
| 2d6d6d24a4 | |||
| 00993a85db | |||
| 4f7e708255 | |||
| 238e4839e0 | |||
| b0ad806a93 | |||
| 453b4fd922 | |||
| bb0d24809e | |||
| 3abd4c4af9 | |||
| 2e5e77b4e5 | |||
| e79cf5de7a | |||
| c15eaca205 | |||
| 496c99ccf1 | |||
| 992622e8d1 | |||
| cabe36c822 | |||
| d84b67c460 | |||
| 1c28950b53 | |||
| b54bcdd9e9 | |||
| 9ec6c958c8 | |||
| 25deac6ea9 | |||
| a5ac10b20d | |||
| 3de40ccad3 | |||
| 6a5dc9b62c | |||
| b6014a72e0 | |||
| 245b47b8b3 | |||
| e33f23c18f | |||
| 33012bc328 | |||
| 447bd4820c | |||
| 72e89dae77 | |||
| 1cd0a8c0fb | |||
| a9a430f856 | |||
| 0ee4c50a24 | |||
| 714f0d97a9 | |||
| d622ddfbf3 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -9,3 +9,4 @@ db.sqlite3 | ||||
| dist/ | ||||
| .DS_Store | ||||
| .python-version | ||||
| .direnv | ||||
|  | ||||
| @ -1,10 +1,13 @@ | ||||
| repos: | ||||
| -   repo: https://github.com/psf/black | ||||
|     rev: 24.3.0 | ||||
|     hooks: | ||||
|     -   id: black | ||||
| # disable due to incomaptible formatting between | ||||
| # black and ruff | ||||
| # TODO: replace with ruff when it works on NixOS | ||||
| # -   repo: https://github.com/psf/black | ||||
| #     rev: 24.8.0 | ||||
| #     hooks: | ||||
| #     -   id: black | ||||
| - repo: https://github.com/pycqa/isort | ||||
|   rev: 5.12.0 | ||||
|   rev: 5.13.2 | ||||
|   hooks: | ||||
|     - id: isort | ||||
|       name: isort (python) | ||||
| @ -12,4 +15,6 @@ repos: | ||||
|   rev: v1.34.0 | ||||
|   hooks: | ||||
|     - id: djlint-reformat-django | ||||
|       args: ["--ignore", "H011"] | ||||
|     - id: djlint-django | ||||
|       args: ["--ignore", "H011"] | ||||
|  | ||||
							
								
								
									
										11
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| { | ||||
|     "recommendations": [ | ||||
|         "charliermarsh.ruff", | ||||
|         "ms-python.python", | ||||
|         "ms-python.vscode-pylance", | ||||
|         "ms-python.debugpy", | ||||
|         "batisteo.vscode-django", | ||||
|         "bradlc.vscode-tailwindcss", | ||||
|         "EditorConfig.EditorConfig" | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										26
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -4,8 +4,30 @@ | ||||
|     ], | ||||
|     "python.testing.unittestEnabled": false, | ||||
|     "python.testing.pytestEnabled": true, | ||||
|     "python.analysis.typeCheckingMode": "basic", | ||||
|     "python.analysis.typeCheckingMode": "strict", | ||||
|     "[python]": { | ||||
|         "editor.defaultFormatter": "ms-python.black-formatter" | ||||
|         "editor.defaultFormatter": "charliermarsh.ruff", | ||||
|         "editor.formatOnSave": true, | ||||
|         "editor.codeActionsOnSave": { | ||||
|             "source.fixAll": "explicit", | ||||
|             "source.organizeImports": "explicit" | ||||
|         }, | ||||
|     }, | ||||
|     "ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"], | ||||
|     "tailwind-fold.supportedLanguages": [ | ||||
|         "html", | ||||
|         "typescriptreact", | ||||
|         "javascriptreact", | ||||
|         "typescript", | ||||
|         "javascript", | ||||
|         "vue-html", | ||||
|         "vue", | ||||
|         "php", | ||||
|         "markdown", | ||||
|         "coffeescript", | ||||
|         "svelte", | ||||
|         "astro", | ||||
|         "erb", | ||||
|         "django-html" | ||||
|     ] | ||||
| } | ||||
|  | ||||
| @ -5,6 +5,8 @@ | ||||
| * Require login by default | ||||
| * Add stats for dropped purchases, monthly playtimes | ||||
| * Allow deleting purchases | ||||
| * Add all-time stats | ||||
| * Manage purchases | ||||
|  | ||||
| ## Improved | ||||
| * mark refunded purchases red on game overview | ||||
|  | ||||
| @ -70,9 +70,15 @@ form label { | ||||
| } | ||||
|  | ||||
| @layer utilities { | ||||
|   .min-w-20char { | ||||
|     min-width: 20ch; | ||||
|   } | ||||
|   .max-w-20char { | ||||
|     max-width: 20ch; | ||||
|   } | ||||
|   .min-w-30char { | ||||
|     min-width: 30ch; | ||||
|   } | ||||
|   .max-w-30char { | ||||
|     max-width: 30ch; | ||||
|   } | ||||
| @ -120,14 +126,6 @@ textarea:disabled { | ||||
|   @apply mx-1; | ||||
| } | ||||
|  | ||||
| th { | ||||
|   @apply text-right; | ||||
| } | ||||
|  | ||||
| th label { | ||||
|   @apply mr-4; | ||||
| } | ||||
|  | ||||
| .basic-button-container { | ||||
|   @apply flex space-x-2 justify-center; | ||||
| } | ||||
| @ -170,4 +168,4 @@ th label { | ||||
|     @apply inline-block truncate max-w-20char transition-all 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; | ||||
|      | ||||
|   }   | ||||
| } */ | ||||
| } */ | ||||
|  | ||||
| @ -12,7 +12,7 @@ def _safe_timedelta(duration: timedelta | int | None): | ||||
|  | ||||
|  | ||||
| def format_duration( | ||||
|     duration: timedelta | int | None, format_string: str = "%H hours" | ||||
|     duration: timedelta | int | float | None, format_string: str = "%H hours" | ||||
| ) -> str: | ||||
|     """ | ||||
|     Format timedelta into the specified format_string. | ||||
|  | ||||
							
								
								
									
										119
									
								
								common/utils.py
									
									
									
									
									
								
							
							
						
						
									
										119
									
								
								common/utils.py
									
									
									
									
									
								
							| @ -1,3 +1,99 @@ | ||||
| from random import choices | ||||
| from string import ascii_lowercase | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from django.template.loader import render_to_string | ||||
| from django.urls import NoReverseMatch, reverse | ||||
| from django.utils.safestring import mark_safe | ||||
|  | ||||
|  | ||||
| def Popover( | ||||
|     wrapped_content: str, | ||||
|     popover_content: str = "", | ||||
| ) -> str: | ||||
|     id = randomid() | ||||
|     if popover_content == "": | ||||
|         popover_content = wrapped_content | ||||
|     content = f"<span data-popover-target={id}>{wrapped_content}</span>" | ||||
|     result = mark_safe( | ||||
|         str(content) | ||||
|         + render_to_string( | ||||
|             "cotton/popover.html", | ||||
|             { | ||||
|                 "id": id, | ||||
|                 "slot": popover_content, | ||||
|             }, | ||||
|         ) | ||||
|     ) | ||||
|     return result | ||||
|  | ||||
|  | ||||
| HTMLAttribute = tuple[str, str] | ||||
| HTMLTag = str | ||||
|  | ||||
|  | ||||
| def Component( | ||||
|     attributes: list[HTMLAttribute] = [], | ||||
|     children: list[HTMLTag] | HTMLTag = [], | ||||
|     template: str = "", | ||||
|     tag_name: str = "", | ||||
| ) -> HTMLTag: | ||||
|     if not tag_name and not template: | ||||
|         raise ValueError("One of template or tag_name is required.") | ||||
|     if isinstance(children, str): | ||||
|         children = [children] | ||||
|     childrenBlob = "\n".join(children) | ||||
|     attributesList = [f'{name} = "{value}"' for name, value in attributes] | ||||
|     attributesBlob = " ".join(attributesList) | ||||
|     tag: str = "" | ||||
|     if tag_name != "": | ||||
|         tag = f"<a {attributesBlob}>{childrenBlob}</a>" | ||||
|     elif template != "": | ||||
|         tag = render_to_string( | ||||
|             template, | ||||
|             {name: value for name, value in attributes} | {"slot": "\n".join(children)}, | ||||
|         ) | ||||
|     return mark_safe(tag) | ||||
|  | ||||
|  | ||||
| def A( | ||||
|     attributes: list[HTMLAttribute] = [], | ||||
|     children: list[HTMLTag] | HTMLTag = [], | ||||
|     url: str | Callable[..., Any] = "", | ||||
| ): | ||||
|     """ | ||||
|     Returns the HTML tag "a". | ||||
|     "url" can either be: | ||||
|         - URL (string) | ||||
|         - path name passed to reverse() (string) | ||||
|         - function | ||||
|     """ | ||||
|     additional_attributes = [] | ||||
|     if url: | ||||
|         if type(url) is str: | ||||
|             try: | ||||
|                 url_result = reverse(url) | ||||
|             except NoReverseMatch: | ||||
|                 url_result = url | ||||
|         elif callable(url): | ||||
|             url_result = url() | ||||
|         else: | ||||
|             raise TypeError("'url' is neither str nor function.") | ||||
|         additional_attributes = [("href", url_result)] | ||||
|     return Component( | ||||
|         tag_name="a", attributes=attributes + additional_attributes, children=children | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def Button( | ||||
|     attributes: list[HTMLAttribute] = [], | ||||
|     children: list[HTMLTag] | HTMLTag = [], | ||||
| ): | ||||
|     return Component( | ||||
|         template="cotton/button.html", attributes=attributes, children=children | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def safe_division(numerator: int | float, denominator: int | float) -> int | float: | ||||
|     """ | ||||
|     Divides without triggering division by zero exception. | ||||
| @ -9,7 +105,7 @@ def safe_division(numerator: int | float, denominator: int | float) -> int | flo | ||||
|         return 0 | ||||
|  | ||||
|  | ||||
| def safe_getattr(obj, attr_chain, default=None): | ||||
| def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object: | ||||
|     """ | ||||
|     Safely get the nested attribute from an object. | ||||
|  | ||||
| @ -28,3 +124,24 @@ def safe_getattr(obj, attr_chain, default=None): | ||||
|         except AttributeError: | ||||
|             return default | ||||
|     return obj | ||||
|  | ||||
|  | ||||
| def truncate(input_string: str, length: int = 30, ellipsis: str = "…") -> str: | ||||
|     return ( | ||||
|         (f"{input_string[:length-len(ellipsis)]}{ellipsis}") | ||||
|         if len(input_string) > 30 | ||||
|         else input_string | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def truncate_with_popover(input_string: str) -> str: | ||||
|     if (truncated := truncate(input_string)) != input_string: | ||||
|         print(f"Not the same after: {truncated=}") | ||||
|         return Popover(wrapped_content=truncated, popover_content=input_string) | ||||
|     else: | ||||
|         print("Strings are the same!") | ||||
|         return input_string | ||||
|  | ||||
|  | ||||
| def randomid(seed: str = "", length: int = 10) -> str: | ||||
|     return seed + "".join(choices(ascii_lowercase, k=length)) | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| from django import forms | ||||
| from django.urls import reverse | ||||
| from common.utils import safe_getattr | ||||
|  | ||||
| from common.utils import safe_getattr | ||||
| from games.models import Device, Edition, Game, Platform, Purchase, Session | ||||
|  | ||||
| custom_date_widget = forms.DateInput(attrs={"type": "date"}) | ||||
|  | ||||
							
								
								
									
										25
									
								
								games/migrations/0035_alter_session_device.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								games/migrations/0035_alter_session_device.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| # 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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										19
									
								
								games/migrations/0036_alter_edition_platform.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								games/migrations/0036_alter_edition_platform.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| # 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'), | ||||
|         ), | ||||
|     ] | ||||
| @ -2,7 +2,7 @@ from datetime import timedelta | ||||
|  | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.db import models | ||||
| from django.db.models import F, Manager, Sum | ||||
| from django.db.models import F, Sum | ||||
| from django.utils import timezone | ||||
|  | ||||
| from common.time import format_duration | ||||
| @ -15,6 +15,18 @@ class Game(models.Model): | ||||
|     wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     session_average: float | int | timedelta | None | ||||
|     session_count: int | None | ||||
|  | ||||
|     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) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
| @ -23,11 +35,11 @@ class Edition(models.Model): | ||||
|     class Meta: | ||||
|         unique_together = [["name", "platform", "year_released"]] | ||||
|  | ||||
|     game = models.ForeignKey("Game", on_delete=models.CASCADE) | ||||
|     game = models.ForeignKey(Game, on_delete=models.CASCADE) | ||||
|     name = models.CharField(max_length=255) | ||||
|     sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||
|     platform = models.ForeignKey( | ||||
|         "Platform", on_delete=models.CASCADE, null=True, blank=True, default=None | ||||
|         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) | ||||
| @ -83,9 +95,9 @@ class Purchase(models.Model): | ||||
|  | ||||
|     objects = PurchaseQueryset().as_manager() | ||||
|  | ||||
|     edition = models.ForeignKey("Edition", on_delete=models.CASCADE) | ||||
|     edition = models.ForeignKey(Edition, on_delete=models.CASCADE) | ||||
|     platform = models.ForeignKey( | ||||
|         "Platform", on_delete=models.CASCADE, default=None, null=True, blank=True | ||||
|         Platform, on_delete=models.CASCADE, default=None, null=True, blank=True | ||||
|     ) | ||||
|     date_purchased = models.DateField() | ||||
|     date_refunded = models.DateField(blank=True, null=True) | ||||
| @ -100,7 +112,7 @@ class Purchase(models.Model): | ||||
|     type = models.CharField(max_length=255, choices=TYPES, default=GAME) | ||||
|     name = models.CharField(max_length=255, default="", null=True, blank=True) | ||||
|     related_purchase = models.ForeignKey( | ||||
|         "Purchase", | ||||
|         "self", | ||||
|         on_delete=models.SET_NULL, | ||||
|         default=None, | ||||
|         null=True, | ||||
| @ -135,15 +147,6 @@ class Purchase(models.Model): | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class Platform(models.Model): | ||||
|     name = models.CharField(max_length=255) | ||||
|     group = models.CharField(max_length=255, null=True, blank=True, default=None) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
|  | ||||
| class SessionQuerySet(models.QuerySet): | ||||
|     def total_duration_formatted(self): | ||||
|         return format_duration(self.total_duration_unformatted()) | ||||
| @ -172,14 +175,14 @@ class Session(models.Model): | ||||
|     class Meta: | ||||
|         get_latest_by = "timestamp_start" | ||||
|  | ||||
|     purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) | ||||
|     purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE) | ||||
|     timestamp_start = models.DateTimeField() | ||||
|     timestamp_end = models.DateTimeField(blank=True, null=True) | ||||
|     duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) | ||||
|     duration_calculated = models.DurationField(blank=True, null=True) | ||||
|     device = models.ForeignKey( | ||||
|         "Device", | ||||
|         on_delete=models.CASCADE, | ||||
|         on_delete=models.SET_DEFAULT, | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=None, | ||||
| @ -220,7 +223,7 @@ class Session(models.Model): | ||||
|     def duration_sum(self) -> str: | ||||
|         return Session.objects.all().total_duration_formatted() | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|     def save(self, *args, **kwargs) -> None: | ||||
|         if self.timestamp_start != None and self.timestamp_end != None: | ||||
|             self.duration_calculated = self.timestamp_end - self.timestamp_start | ||||
|         else: | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| /* | ||||
| ! tailwindcss v3.4.4 | MIT License | https://tailwindcss.com | ||||
| ! tailwindcss v3.4.7 | MIT License | https://tailwindcss.com | ||||
| */ | ||||
|  | ||||
| /* | ||||
| @ -1382,6 +1382,14 @@ input:checked + .toggle-bg { | ||||
|   margin-bottom: 0.75rem; | ||||
| } | ||||
|  | ||||
| .mb-4 { | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
|  | ||||
| .mb-6 { | ||||
|   margin-bottom: 1.5rem; | ||||
| } | ||||
|  | ||||
| .mb-8 { | ||||
|   margin-bottom: 2rem; | ||||
| } | ||||
| @ -1390,14 +1398,22 @@ input:checked + .toggle-bg { | ||||
|   margin-inline-end: 0.5rem; | ||||
| } | ||||
|  | ||||
| .ml-1 { | ||||
|   margin-left: 0.25rem; | ||||
| } | ||||
|  | ||||
| .mr-4 { | ||||
|   margin-right: 1rem; | ||||
| } | ||||
|  | ||||
| .ms-0 { | ||||
|   margin-inline-start: 0px; | ||||
| } | ||||
|  | ||||
| .ms-2 { | ||||
|   margin-inline-start: 0.5rem; | ||||
| } | ||||
|  | ||||
| .ms-2\.5 { | ||||
|   margin-inline-start: 0.625rem; | ||||
| } | ||||
|  | ||||
| .mt-2 { | ||||
|   margin-top: 0.5rem; | ||||
| } | ||||
| @ -1443,12 +1459,16 @@ input:checked + .toggle-bg { | ||||
|   height: 1.5rem; | ||||
| } | ||||
|  | ||||
| .h-10 { | ||||
|   height: 2.5rem; | ||||
| } | ||||
|  | ||||
| .h-12 { | ||||
|   height: 3rem; | ||||
| } | ||||
|  | ||||
| .h-24 { | ||||
|   height: 6rem; | ||||
| .h-2\.5 { | ||||
|   height: 0.625rem; | ||||
| } | ||||
|  | ||||
| .h-4 { | ||||
| @ -1463,6 +1483,10 @@ input:checked + .toggle-bg { | ||||
|   height: 1.5rem; | ||||
| } | ||||
|  | ||||
| .h-8 { | ||||
|   height: 2rem; | ||||
| } | ||||
|  | ||||
| .h-9 { | ||||
|   height: 2.25rem; | ||||
| } | ||||
| @ -1475,6 +1499,14 @@ input:checked + .toggle-bg { | ||||
|   width: 50%; | ||||
| } | ||||
|  | ||||
| .w-10 { | ||||
|   width: 2.5rem; | ||||
| } | ||||
|  | ||||
| .w-2\.5 { | ||||
|   width: 0.625rem; | ||||
| } | ||||
|  | ||||
| .w-24 { | ||||
|   width: 6rem; | ||||
| } | ||||
| @ -1483,6 +1515,10 @@ input:checked + .toggle-bg { | ||||
|   width: 1rem; | ||||
| } | ||||
|  | ||||
| .w-44 { | ||||
|   width: 11rem; | ||||
| } | ||||
|  | ||||
| .w-5 { | ||||
|   width: 1.25rem; | ||||
| } | ||||
| @ -1495,26 +1531,10 @@ input:checked + .toggle-bg { | ||||
|   width: 16rem; | ||||
| } | ||||
|  | ||||
| .w-7 { | ||||
|   width: 1.75rem; | ||||
| } | ||||
|  | ||||
| .w-auto { | ||||
|   width: auto; | ||||
| } | ||||
|  | ||||
| .w-full { | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .min-w-14 { | ||||
|   min-width: 3.5rem; | ||||
| } | ||||
|  | ||||
| .max-w-24 { | ||||
|   max-width: 6rem; | ||||
| } | ||||
|  | ||||
| .max-w-80 { | ||||
|   max-width: 20rem; | ||||
| } | ||||
| @ -1523,6 +1543,10 @@ input:checked + .toggle-bg { | ||||
|   max-width: 1024px; | ||||
| } | ||||
|  | ||||
| .max-w-screen-xl { | ||||
|   max-width: 1280px; | ||||
| } | ||||
|  | ||||
| .max-w-sm { | ||||
|   max-width: 24rem; | ||||
| } | ||||
| @ -1659,10 +1683,10 @@ input:checked + .toggle-bg { | ||||
|   gap: 1.25rem; | ||||
| } | ||||
|  | ||||
| .space-x-1 > :not([hidden]) ~ :not([hidden]) { | ||||
| .-space-x-px > :not([hidden]) ~ :not([hidden]) { | ||||
|   --tw-space-x-reverse: 0; | ||||
|   margin-right: calc(0.25rem * var(--tw-space-x-reverse)); | ||||
|   margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse))); | ||||
|   margin-right: calc(-1px * var(--tw-space-x-reverse)); | ||||
|   margin-left: calc(-1px * calc(1 - var(--tw-space-x-reverse))); | ||||
| } | ||||
|  | ||||
| .space-x-2 > :not([hidden]) ~ :not([hidden]) { | ||||
| @ -1671,6 +1695,23 @@ input:checked + .toggle-bg { | ||||
|   margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); | ||||
| } | ||||
|  | ||||
| .space-x-3 > :not([hidden]) ~ :not([hidden]) { | ||||
|   --tw-space-x-reverse: 0; | ||||
|   margin-right: calc(0.75rem * var(--tw-space-x-reverse)); | ||||
|   margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); | ||||
| } | ||||
|  | ||||
| .divide-y > :not([hidden]) ~ :not([hidden]) { | ||||
|   --tw-divide-y-reverse: 0; | ||||
|   border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); | ||||
|   border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); | ||||
| } | ||||
|  | ||||
| .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { | ||||
|   --tw-divide-opacity: 1; | ||||
|   border-color: rgb(243 244 246 / var(--tw-divide-opacity)); | ||||
| } | ||||
|  | ||||
| .self-center { | ||||
|   align-self: center; | ||||
| } | ||||
| @ -1679,6 +1720,10 @@ input:checked + .toggle-bg { | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .overflow-x-auto { | ||||
|   overflow-x: auto; | ||||
| } | ||||
|  | ||||
| .truncate { | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
| @ -1693,8 +1738,8 @@ input:checked + .toggle-bg { | ||||
|   text-wrap: wrap; | ||||
| } | ||||
|  | ||||
| .rounded-full { | ||||
|   border-radius: 9999px; | ||||
| .rounded { | ||||
|   border-radius: 0.25rem; | ||||
| } | ||||
|  | ||||
| .rounded-lg { | ||||
| @ -1737,6 +1782,10 @@ input:checked + .toggle-bg { | ||||
|   border-width: 0px; | ||||
| } | ||||
|  | ||||
| .border-b { | ||||
|   border-bottom-width: 1px; | ||||
| } | ||||
|  | ||||
| .border-blue-600 { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(28 100 242 / var(--tw-border-opacity)); | ||||
| @ -1767,6 +1816,11 @@ input:checked + .toggle-bg { | ||||
|   border-color: rgb(220 215 254 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-blue-100 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(225 239 254 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-blue-700 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(26 86 219 / var(--tw-bg-opacity)); | ||||
| @ -1782,6 +1836,16 @@ input:checked + .toggle-bg { | ||||
|   background-color: rgb(229 231 235 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-gray-400 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(156 163 175 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-gray-50 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(249 250 251 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-gray-800 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(31 41 55 / var(--tw-bg-opacity)); | ||||
| @ -1796,11 +1860,6 @@ input:checked + .toggle-bg { | ||||
|   background-color: rgb(5 122 85 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-violet-600 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(124 58 237 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-white { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(255 255 255 / var(--tw-bg-opacity)); | ||||
| @ -1831,6 +1890,11 @@ input:checked + .toggle-bg { | ||||
|   padding-right: 0.5rem; | ||||
| } | ||||
|  | ||||
| .px-2\.5 { | ||||
|   padding-left: 0.625rem; | ||||
|   padding-right: 0.625rem; | ||||
| } | ||||
|  | ||||
| .px-3 { | ||||
|   padding-left: 0.75rem; | ||||
|   padding-right: 0.75rem; | ||||
| @ -1846,6 +1910,16 @@ input:checked + .toggle-bg { | ||||
|   padding-right: 1.25rem; | ||||
| } | ||||
|  | ||||
| .px-6 { | ||||
|   padding-left: 1.5rem; | ||||
|   padding-right: 1.5rem; | ||||
| } | ||||
|  | ||||
| .py-0\.5 { | ||||
|   padding-top: 0.125rem; | ||||
|   padding-bottom: 0.125rem; | ||||
| } | ||||
|  | ||||
| .py-1 { | ||||
|   padding-top: 0.25rem; | ||||
|   padding-bottom: 0.25rem; | ||||
| @ -1866,20 +1940,9 @@ input:checked + .toggle-bg { | ||||
|   padding-bottom: 0.75rem; | ||||
| } | ||||
|  | ||||
| .pb-16 { | ||||
|   padding-bottom: 4rem; | ||||
| } | ||||
|  | ||||
| .pl-3 { | ||||
|   padding-left: 0.75rem; | ||||
| } | ||||
|  | ||||
| .pr-4 { | ||||
|   padding-right: 1rem; | ||||
| } | ||||
|  | ||||
| .pt-1 { | ||||
|   padding-top: 0.25rem; | ||||
| .py-4 { | ||||
|   padding-top: 1rem; | ||||
|   padding-bottom: 1rem; | ||||
| } | ||||
|  | ||||
| .pt-2 { | ||||
| @ -1890,16 +1953,20 @@ input:checked + .toggle-bg { | ||||
|   padding-top: 2rem; | ||||
| } | ||||
|  | ||||
| .text-left { | ||||
|   text-align: left; | ||||
| } | ||||
|  | ||||
| .text-center { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .align-top { | ||||
|   vertical-align: top; | ||||
| .text-right { | ||||
|   text-align: right; | ||||
| } | ||||
|  | ||||
| .font-condensed { | ||||
|   font-family: IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; | ||||
| .align-top { | ||||
|   vertical-align: top; | ||||
| } | ||||
|  | ||||
| .font-mono { | ||||
| @ -1959,14 +2026,26 @@ input:checked + .toggle-bg { | ||||
|   font-weight: 700; | ||||
| } | ||||
|  | ||||
| .font-extrabold { | ||||
|   font-weight: 800; | ||||
| } | ||||
|  | ||||
| .font-medium { | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .font-normal { | ||||
|   font-weight: 400; | ||||
| } | ||||
|  | ||||
| .font-semibold { | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| .uppercase { | ||||
|   text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .leading-6 { | ||||
|   line-height: 1.5rem; | ||||
| } | ||||
| @ -1975,11 +2054,33 @@ input:checked + .toggle-bg { | ||||
|   line-height: 2.25rem; | ||||
| } | ||||
|  | ||||
| .leading-none { | ||||
|   line-height: 1; | ||||
| } | ||||
|  | ||||
| .leading-tight { | ||||
|   line-height: 1.25; | ||||
| } | ||||
|  | ||||
| .tracking-tight { | ||||
|   letter-spacing: -0.025em; | ||||
| } | ||||
|  | ||||
| .text-blue-600 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(28 100 242 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .text-blue-800 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(30 66 159 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .text-gray-300 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(209 213 219 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .text-gray-400 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(156 163 175 / var(--tw-text-opacity)); | ||||
| @ -2280,14 +2381,6 @@ textarea:disabled:is(.dark *) { | ||||
|   margin-right: 0.25rem; | ||||
| } | ||||
|  | ||||
| th { | ||||
|   text-align: right; | ||||
| } | ||||
|  | ||||
| th label { | ||||
|   margin-right: 1rem; | ||||
| } | ||||
|  | ||||
| .basic-button-container { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| @ -2390,11 +2483,26 @@ th label { | ||||
|   }   | ||||
| } */ | ||||
|  | ||||
| .odd\:bg-white:nth-child(odd) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(255 255 255 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .even\:bg-gray-50:nth-child(even) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(249 250 251 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:border-gray-300:hover { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(209 213 219 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:border-green-600:hover { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(5 122 85 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-blue-800:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(30 66 159 / var(--tw-bg-opacity)); | ||||
| @ -2405,9 +2513,14 @@ th label { | ||||
|   background-color: rgb(243 244 246 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-gray-400:hover { | ||||
| .hover\:bg-gray-50:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(156 163 175 / var(--tw-bg-opacity)); | ||||
|   background-color: rgb(249 250 251 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-green-500:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(14 159 110 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-green-700:hover { | ||||
| @ -2420,9 +2533,9 @@ th label { | ||||
|   background-color: rgb(253 232 232 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-violet-700:hover { | ||||
| .hover\:bg-red-500:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(109 40 217 / var(--tw-bg-opacity)); | ||||
|   background-color: rgb(240 82 82 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-white:hover { | ||||
| @ -2445,13 +2558,19 @@ th label { | ||||
|   color: rgb(75 85 99 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:text-gray-700:hover { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(55 65 81 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:text-gray-900:hover { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(17 24 39 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:underline:hover { | ||||
|   text-decoration-line: underline; | ||||
| .hover\:text-white:hover { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(255 255 255 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .focus\:z-10:focus { | ||||
| @ -2500,9 +2619,9 @@ th label { | ||||
|   --tw-ring-color: rgb(14 159 110 / var(--tw-ring-opacity)); | ||||
| } | ||||
|  | ||||
| .focus\:ring-violet-500:focus { | ||||
| .focus\:ring-green-700:focus { | ||||
|   --tw-ring-opacity: 1; | ||||
|   --tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity)); | ||||
|   --tw-ring-color: rgb(4 108 78 / var(--tw-ring-opacity)); | ||||
| } | ||||
|  | ||||
| .focus\:ring-offset-2:focus { | ||||
| @ -2513,10 +2632,6 @@ th label { | ||||
|   --tw-ring-offset-color: #C3DDFD; | ||||
| } | ||||
|  | ||||
| .focus\:ring-offset-violet-200:focus { | ||||
|   --tw-ring-offset-color: #ddd6fe; | ||||
| } | ||||
|  | ||||
| .group:hover .group-hover\:absolute { | ||||
|   position: absolute; | ||||
| } | ||||
| @ -2529,10 +2644,6 @@ th label { | ||||
|   top: -2rem; | ||||
| } | ||||
|  | ||||
| .group:hover .group-hover\:block { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .group:hover .group-hover\:min-w-60 { | ||||
|   min-width: 15rem; | ||||
| } | ||||
| @ -2555,11 +2666,6 @@ th label { | ||||
|   padding-right: 1.5rem; | ||||
| } | ||||
|  | ||||
| .group:hover .group-hover\:py-3 { | ||||
|   padding-top: 0.75rem; | ||||
|   padding-bottom: 0.75rem; | ||||
| } | ||||
|  | ||||
| .group:hover .group-hover\:py-3\.5 { | ||||
|   padding-top: 0.875rem; | ||||
|   padding-bottom: 0.875rem; | ||||
| @ -2586,6 +2692,11 @@ th label { | ||||
|   outline-color: #AC94FA; | ||||
| } | ||||
|  | ||||
| .dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) { | ||||
|   --tw-divide-opacity: 1; | ||||
|   border-color: rgb(75 85 99 / var(--tw-divide-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:border-blue-500:is(.dark *) { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(63 131 248 / var(--tw-border-opacity)); | ||||
| @ -2610,6 +2721,11 @@ th label { | ||||
|   border-color: transparent; | ||||
| } | ||||
|  | ||||
| .dark\:bg-blue-200:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(195 221 253 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:bg-blue-600:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(28 100 242 / var(--tw-bg-opacity)); | ||||
| @ -2653,6 +2769,16 @@ th label { | ||||
|   color: rgb(63 131 248 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:text-blue-800:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(30 66 159 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:text-gray-200:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(229 231 235 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:text-gray-400:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(156 163 175 / var(--tw-text-opacity)); | ||||
| @ -2663,16 +2789,16 @@ th label { | ||||
|   color: rgb(107 114 128 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:text-gray-600:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(75 85 99 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:text-slate-400:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(148 163 184 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:text-slate-500:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(100 116 139 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:text-slate-600:is(.dark *) { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(71 85 105 / var(--tw-text-opacity)); | ||||
| @ -2683,6 +2809,26 @@ th label { | ||||
|   color: rgb(255 255 255 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .odd\:dark\:bg-gray-900:is(.dark *):nth-child(odd) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(17 24 39 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .even\:dark\:bg-gray-800:is(.dark *):nth-child(even) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(31 41 55 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:hover\:border-green-700:hover:is(.dark *) { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(4 108 78 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:hover\:border-red-700:hover:is(.dark *) { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(200 30 30 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:hover\:bg-blue-700:hover:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(26 86 219 / var(--tw-bg-opacity)); | ||||
| @ -2703,6 +2849,11 @@ th label { | ||||
|   background-color: rgb(31 41 55 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:hover\:bg-green-600:hover:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(5 122 85 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:hover\:bg-red-700:hover:is(.dark *) { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(200 30 30 / var(--tw-bg-opacity)); | ||||
| @ -2738,11 +2889,17 @@ th label { | ||||
|   --tw-ring-color: rgb(30 66 159 / var(--tw-ring-opacity)); | ||||
| } | ||||
|  | ||||
| @media (min-width: 640px) { | ||||
|   .sm\:inline { | ||||
|     display: inline; | ||||
|   } | ||||
| .dark\:focus\:ring-gray-600:focus:is(.dark *) { | ||||
|   --tw-ring-opacity: 1; | ||||
|   --tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity)); | ||||
| } | ||||
|  | ||||
| .dark\:focus\:ring-green-500:focus:is(.dark *) { | ||||
|   --tw-ring-opacity: 1; | ||||
|   --tw-ring-color: rgb(14 159 110 / var(--tw-ring-opacity)); | ||||
| } | ||||
|  | ||||
| @media (min-width: 640px) { | ||||
|   .sm\:table-cell { | ||||
|     display: table-cell; | ||||
|   } | ||||
| @ -2751,49 +2908,84 @@ th label { | ||||
|     max-width: 28rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:max-w-screen-sm { | ||||
|     max-width: 640px; | ||||
|   } | ||||
|  | ||||
|   .sm\:max-w-xl { | ||||
|     max-width: 36rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:rounded-lg { | ||||
|     border-radius: 0.5rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:px-4 { | ||||
|     padding-left: 1rem; | ||||
|     padding-right: 1rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:pl-12 { | ||||
|     padding-left: 3rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:pl-2 { | ||||
|     padding-left: 0.5rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:pl-4 { | ||||
|     padding-left: 1rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:pl-6 { | ||||
|     padding-left: 1.5rem; | ||||
|   } | ||||
|  | ||||
|   .sm\:decoration-2 { | ||||
|     text-decoration-thickness: 2px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 768px) { | ||||
|   .md\:mb-0 { | ||||
|     margin-bottom: 0px; | ||||
|   } | ||||
|  | ||||
|   .md\:mt-0 { | ||||
|     margin-top: 0px; | ||||
|   } | ||||
|  | ||||
|   .md\:block { | ||||
|     display: block; | ||||
|   } | ||||
|  | ||||
|   .md\:inline { | ||||
|     display: inline; | ||||
|   } | ||||
|  | ||||
|   .md\:hidden { | ||||
|     display: none; | ||||
|   } | ||||
|  | ||||
|   .md\:w-auto { | ||||
|     width: auto; | ||||
|   } | ||||
|  | ||||
|   .md\:max-w-screen-md { | ||||
|     max-width: 768px; | ||||
|   } | ||||
|  | ||||
|   .md\:flex-row { | ||||
|     flex-direction: row; | ||||
|   } | ||||
|  | ||||
|   .md\:space-x-8 > :not([hidden]) ~ :not([hidden]) { | ||||
|     --tw-space-x-reverse: 0; | ||||
|     margin-right: calc(2rem * var(--tw-space-x-reverse)); | ||||
|     margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); | ||||
|   } | ||||
|  | ||||
|   .md\:border-0 { | ||||
|     border-width: 0px; | ||||
|   } | ||||
|  | ||||
|   .md\:bg-transparent { | ||||
|     background-color: transparent; | ||||
|   } | ||||
|  | ||||
|   .md\:bg-white { | ||||
|     --tw-bg-opacity: 1; | ||||
|     background-color: rgb(255 255 255 / var(--tw-bg-opacity)); | ||||
|   } | ||||
|  | ||||
|   .md\:p-0 { | ||||
|     padding: 0px; | ||||
|   } | ||||
|  | ||||
|   .md\:px-6 { | ||||
|     padding-left: 1.5rem; | ||||
|     padding-right: 1.5rem; | ||||
| @ -2803,6 +2995,43 @@ th label { | ||||
|     padding-top: 0.5rem; | ||||
|     padding-bottom: 0.5rem; | ||||
|   } | ||||
|  | ||||
|   .md\:text-blue-700 { | ||||
|     --tw-text-opacity: 1; | ||||
|     color: rgb(26 86 219 / var(--tw-text-opacity)); | ||||
|   } | ||||
|  | ||||
|   .md\:hover\:bg-transparent:hover { | ||||
|     background-color: transparent; | ||||
|   } | ||||
|  | ||||
|   .md\:hover\:text-blue-700:hover { | ||||
|     --tw-text-opacity: 1; | ||||
|     color: rgb(26 86 219 / var(--tw-text-opacity)); | ||||
|   } | ||||
|  | ||||
|   .md\:dark\:bg-gray-900:is(.dark *) { | ||||
|     --tw-bg-opacity: 1; | ||||
|     background-color: rgb(17 24 39 / var(--tw-bg-opacity)); | ||||
|   } | ||||
|  | ||||
|   .md\:dark\:bg-transparent:is(.dark *) { | ||||
|     background-color: transparent; | ||||
|   } | ||||
|  | ||||
|   .md\:dark\:text-blue-500:is(.dark *) { | ||||
|     --tw-text-opacity: 1; | ||||
|     color: rgb(63 131 248 / var(--tw-text-opacity)); | ||||
|   } | ||||
|  | ||||
|   .md\:dark\:hover\:bg-transparent:hover:is(.dark *) { | ||||
|     background-color: transparent; | ||||
|   } | ||||
|  | ||||
|   .md\:dark\:hover\:text-blue-500:hover:is(.dark *) { | ||||
|     --tw-text-opacity: 1; | ||||
|     color: rgb(63 131 248 / var(--tw-text-opacity)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1024px) { | ||||
| @ -2819,6 +3048,18 @@ th label { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1280px) { | ||||
|   .xl\:max-w-screen-xl { | ||||
|     max-width: 1280px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1536px) { | ||||
|   .\32xl\:max-w-screen-2xl { | ||||
|     max-width: 1536px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .rtl\:rotate-180:where([dir="rtl"], [dir="rtl"] *) { | ||||
|   --tw-rotate: 180deg; | ||||
|   transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); | ||||
| @ -2826,4 +3067,34 @@ th label { | ||||
|  | ||||
| .rtl\:space-x-reverse:where([dir="rtl"], [dir="rtl"] *) > :not([hidden]) ~ :not([hidden]) { | ||||
|   --tw-space-x-reverse: 1; | ||||
| } | ||||
| } | ||||
|  | ||||
| .rtl\:text-left:where([dir="rtl"], [dir="rtl"] *) { | ||||
|   text-align: left; | ||||
| } | ||||
|  | ||||
| .rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) { | ||||
|   text-align: right; | ||||
| } | ||||
|  | ||||
| .\[\&\:first-of-type_button\]\:rounded-s-lg:first-of-type button { | ||||
|   border-start-start-radius: 0.5rem; | ||||
|   border-end-start-radius: 0.5rem; | ||||
| } | ||||
|  | ||||
| .\[\&\:last-of-type_button\]\:rounded-e-lg:last-of-type button { | ||||
|   border-start-end-radius: 0.5rem; | ||||
|   border-end-end-radius: 0.5rem; | ||||
| } | ||||
|  | ||||
| .\[\&_a\]\:underline a { | ||||
|   text-decoration-line: underline; | ||||
| } | ||||
|  | ||||
| .\[\&_a\]\:decoration-2 a { | ||||
|   text-decoration-thickness: 2px; | ||||
| } | ||||
|  | ||||
| .\[\&_a\]\:underline-offset-4 a { | ||||
|   text-underline-offset: 4px; | ||||
| } | ||||
|  | ||||
| @ -26,7 +26,9 @@ | ||||
|                 <tr> | ||||
|                     <td></td> | ||||
|                     <td> | ||||
|                         <a href="{% url 'delete_purchase' purchase_id %}" class="text-red-600" onclick="return confirm('Are you sure you want to delete this purchase?');">Delete</a> | ||||
|                         <a href="{% url 'delete_purchase' purchase_id %}" | ||||
|                            class="text-red-600" | ||||
|                            onclick="return confirm('Are you sure you want to delete this purchase?');">Delete</a> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|             {% endif %} | ||||
|  | ||||
| @ -16,8 +16,16 @@ | ||||
|         {% django_htmx_script %} | ||||
|         <link rel="stylesheet" href="{% static 'base.css' %}" /> | ||||
|         <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script> | ||||
|         <script> | ||||
|         // On page load or when changing themes, best to add inline in `head` to avoid FOUC | ||||
|         if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { | ||||
|             document.documentElement.classList.add('dark'); | ||||
|         } else { | ||||
|             document.documentElement.classList.remove('dark') | ||||
|         } | ||||
|         </script> | ||||
|     </head> | ||||
|     <body class="dark" hx-indicator="#indicator"> | ||||
|     <body hx-indicator="#indicator"> | ||||
|         <img id="indicator" | ||||
|              src="{% static 'icons/loading.png' %}" | ||||
|              class="absolute right-3 top-3 animate-spin htmx-indicator" | ||||
| @ -25,95 +33,58 @@ | ||||
|              width="24" | ||||
|              alt="loading indicator" /> | ||||
|         <div class="flex flex-col min-h-screen"> | ||||
|             <nav class="dark:bg-gray-900 border-gray-200 h-24 flex items-center"> | ||||
|                 <div class="container flex flex-wrap items-center justify-between mx-auto"> | ||||
|                     <a href="{% url 'list_sessions_recent' %}" class="flex items-center"> | ||||
|                         <span class="text-4xl"> | ||||
|                             <img src="{% static 'icons/schedule.png' %}" | ||||
|                                  height="48" | ||||
|                                  width="48" | ||||
|                                  alt="Timetracker Logo" | ||||
|                                  class="mr-4" /> | ||||
|                         </span> | ||||
|                         <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> | ||||
|                     </a> | ||||
|                     <div class="w-full md:block md:w-auto"> | ||||
|                         <ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> | ||||
|                             <li class="relative group"> | ||||
|                                 {% if user.is_authenticated %} | ||||
|                                     <a class="block py-2 pl-3 pr-4 hover:underline" | ||||
|                                        href="{% url 'add_game' %}">New</a> | ||||
|                                     <ul class="absolute hidden text-gray-700 pt-1 group-hover:block  w-auto whitespace-nowrap"> | ||||
|                                         {% if purchase_available %} | ||||
|                                             <li> | ||||
|                                                 <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" | ||||
|                                                    href="{% url 'add_device' %}">Device</a> | ||||
|                                             </li> | ||||
|                                         {% endif %} | ||||
|                                         <li> | ||||
|                                             <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" | ||||
|                                                href="{% url 'add_game' %}">Game</a> | ||||
|                                         </li> | ||||
|                                         {% if game_available and platform_available %} | ||||
|                                             <li> | ||||
|                                                 <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" | ||||
|                                                    href="{% url 'add_edition' %}">Edition</a> | ||||
|                                             </li> | ||||
|                                         {% endif %} | ||||
|                                         <li> | ||||
|                                             <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" | ||||
|                                                href="{% url 'add_platform' %}">Platform</a> | ||||
|                                         </li> | ||||
|                                         {% if edition_available %} | ||||
|                                             <li> | ||||
|                                                 <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" | ||||
|                                                    href="{% url 'add_purchase' %}">Purchase</a> | ||||
|                                             </li> | ||||
|                                         {% endif %} | ||||
|                                         {% if purchase_available %} | ||||
|                                             <li> | ||||
|                                                 <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" | ||||
|                                                    href="{% url 'add_session' %}">Session</a> | ||||
|                                             </li> | ||||
|                                         {% endif %} | ||||
|                                     </ul> | ||||
|                                     </li> | ||||
|                                     {% if session_count > 0 %} | ||||
|                                         <li class="relative group"> | ||||
|                                             <a class="block py-2 pl-3 pr-4 hover:underline" | ||||
|                                                href="{% url 'stats_current_year' %}">Stats</a> | ||||
|                                             <ul class="absolute hidden text-gray-700 pt-1 group-hover:block"> | ||||
|                                                 {% for year in stats_dropdown_year_range %} | ||||
|                                                     <li> | ||||
|                                                         <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" | ||||
|                                                            href="{% url 'stats_by_year' year %}">{{ year }}</a> | ||||
|                                                     </li> | ||||
|                                                 {% endfor %} | ||||
|                                             </ul> | ||||
|                                         </li> | ||||
|                                         <li> | ||||
|                                             <a class="block py-2 pl-3 pr-4 hover:underline" | ||||
|                                                href="{% url 'list_sessions' %}">All Sessions</a> | ||||
|                                         </li> | ||||
|                                         <li> | ||||
|                                             <a class="block py-2 pl-3 pr-4 hover:underline" | ||||
|                                                href="{% url 'logout' %}">Log Out</a> | ||||
|                                         </li> | ||||
|                                     {% endif %} | ||||
|                                 {% endif %} | ||||
|                             </ul> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </nav> | ||||
|                 <div class="flex flex-1 dark:bg-gray-800 justify-center pt-8 pb-16"> | ||||
|                     {% block content %} | ||||
|                         No content here. | ||||
|                     {% endblock content %} | ||||
|                 </div> | ||||
|                 {% load version %} | ||||
|                 <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> | ||||
|             {% include "navbar.html" %} | ||||
|             <div class="flex flex-1 flex-col dark:bg-gray-800 pt-8"> | ||||
|                 {% block content %} | ||||
|                     No content here. | ||||
|                 {% endblock content %} | ||||
|             </div> | ||||
|             {% block scripts %} | ||||
|             {% endblock scripts %} | ||||
|         </body> | ||||
|     </html> | ||||
|             {% load version %} | ||||
|             <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> | ||||
|         </div> | ||||
|         {% block scripts %} | ||||
|         {% endblock scripts %} | ||||
|         <script> | ||||
|         var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); | ||||
|         var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); | ||||
|  | ||||
|         // Change the icons inside the button based on previous settings | ||||
|         if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { | ||||
|             themeToggleLightIcon.classList.remove('hidden'); | ||||
|         } else { | ||||
|             themeToggleDarkIcon.classList.remove('hidden'); | ||||
|         } | ||||
|  | ||||
|         var themeToggleBtn = document.getElementById('theme-toggle'); | ||||
|  | ||||
|         themeToggleBtn.addEventListener('click', function () { | ||||
|  | ||||
|             // toggle icons inside button | ||||
|             themeToggleDarkIcon.classList.toggle('hidden'); | ||||
|             themeToggleLightIcon.classList.toggle('hidden'); | ||||
|  | ||||
|             // if set via local storage previously | ||||
|             if (localStorage.getItem('color-theme')) { | ||||
|                 if (localStorage.getItem('color-theme') === 'light') { | ||||
|                     document.documentElement.classList.add('dark'); | ||||
|                     localStorage.setItem('color-theme', 'dark'); | ||||
|                 } else { | ||||
|                     document.documentElement.classList.remove('dark'); | ||||
|                     localStorage.setItem('color-theme', 'light'); | ||||
|                 } | ||||
|  | ||||
|                 // if NOT set via local storage previously | ||||
|             } else { | ||||
|                 if (document.documentElement.classList.contains('dark')) { | ||||
|                     document.documentElement.classList.remove('dark'); | ||||
|                     localStorage.setItem('color-theme', 'light'); | ||||
|                 } else { | ||||
|                     document.documentElement.classList.add('dark'); | ||||
|                     localStorage.setItem('color-theme', 'dark'); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         }); | ||||
|         </script> | ||||
|     </body> | ||||
| </html> | ||||
|  | ||||
| @ -1,3 +0,0 @@ | ||||
| components: | ||||
|   gamelink: "components/game_link.html" | ||||
|   popover: "components/popover.html" | ||||
| @ -1,13 +0,0 @@ | ||||
| <a href="{{ edit_url }}"> | ||||
|     <button type="button" | ||||
|             title="Edit" | ||||
|             class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg"> | ||||
|         <svg xmlns="http://www.w3.org/2000/svg" | ||||
|              viewBox="0 0 20 20" | ||||
|              fill="currentColor" | ||||
|              class="w-5 h-5"> | ||||
|             <path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" /> | ||||
|             <path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" /> | ||||
|         </svg> | ||||
|     </button> | ||||
| </a> | ||||
| @ -1,9 +0,0 @@ | ||||
| <span class="truncate-container"> | ||||
|     <a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game_id %}"> | ||||
|         {% if children %} | ||||
|             {{ children }} | ||||
|         {% else %} | ||||
|             {{ name }} | ||||
|         {% endif %} | ||||
|     </a> | ||||
| </span> | ||||
| @ -1,9 +0,0 @@ | ||||
| <!-- needs data-popover-target on triggering block --> | ||||
| <!-- id --> | ||||
| <!-- children --> | ||||
| <div data-popover id="{{ id }}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800"> | ||||
|     <div class="px-3 py-2"> | ||||
|         {{ children }} | ||||
|     </div> | ||||
|     <div data-popper-arrow></div> | ||||
| </div> | ||||
							
								
								
									
										5
									
								
								games/templates/cotton/button.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								games/templates/cotton/button.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| <button type="button" | ||||
|         class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 mt-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"> | ||||
|     {{ text }} | ||||
|     {{ slot }} | ||||
| </button> | ||||
							
								
								
									
										20
									
								
								games/templates/cotton/button_group_button_sm.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								games/templates/cotton/button_group_button_sm.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| <c-vars color="gray" /> | ||||
| <a href="{{ href }}" | ||||
|    class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg"> | ||||
|     {% if color == "gray" %} | ||||
|         <button type="button" | ||||
|                 class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> | ||||
|             {{ text }} | ||||
|         </button> | ||||
|     {% elif color == "red" %} | ||||
|         <button type="button" | ||||
|                 class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> | ||||
|             {{ text }} | ||||
|         </button> | ||||
|     {% elif color == "green" %} | ||||
|         <button type="button" | ||||
|                 class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white"> | ||||
|             {{ text }} | ||||
|         </button> | ||||
|     {% endif %} | ||||
| </a> | ||||
							
								
								
									
										6
									
								
								games/templates/cotton/button_group_sm.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								games/templates/cotton/button_group_sm.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| <c-vars color="gray" /> | ||||
| <div class="inline-flex rounded-md shadow-sm" role="group"> | ||||
|     {% for button in buttons %} | ||||
|         <c-button-group-button-sm :href=button.href :text=button.text :color=button.color /> | ||||
|     {% endfor %} | ||||
| </div> | ||||
							
								
								
									
										10
									
								
								games/templates/cotton/gamelink.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								games/templates/cotton/gamelink.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| <span class="truncate-container"> | ||||
|     <a class="underline decoration-slate-500 sm:decoration-2" | ||||
|        href="{% url 'view_game' game_id %}"> | ||||
|         {% if slot %} | ||||
|             {{ slot }} | ||||
|         {% else %} | ||||
|             {{ name }} | ||||
|         {% endif %} | ||||
|     </a> | ||||
| </span> | ||||
							
								
								
									
										8
									
								
								games/templates/cotton/h1.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								games/templates/cotton/h1.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| <h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"> | ||||
|     {{ slot }} | ||||
|     {% if badge %} | ||||
|         <span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2"> | ||||
|             {{ badge }} | ||||
|         </span> | ||||
|     {% endif %} | ||||
| </h1> | ||||
							
								
								
									
										7
									
								
								games/templates/cotton/popover.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								games/templates/cotton/popover.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| <div data-popover | ||||
|      id="{{ id }}" | ||||
|      role="tooltip" | ||||
|      class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800"> | ||||
|     <div class="px-3 py-2">{{ slot }}</div> | ||||
|     <div data-popper-arrow></div> | ||||
| </div> | ||||
							
								
								
									
										55
									
								
								games/templates/cotton/simple_table.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								games/templates/cotton/simple_table.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | ||||
| {% load param_utils %} | ||||
| <div class="shadow-md sm:rounded-lg" hx-boost="false"> | ||||
|     <div class="relative overflow-x-auto sm:rounded-lg"> | ||||
|         <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> | ||||
|             {% if header_action %} | ||||
|                 <c-table-header> | ||||
|                 {{ header_action }} | ||||
|                 </c-table-header> | ||||
|             {% endif %} | ||||
|             <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> | ||||
|                 <tr> | ||||
|                     {% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %} | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for row in rows %}<c-table-row :data=row />{% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|     </div> | ||||
|     {% if page_obj and elided_page_range %} | ||||
|         <nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4" | ||||
|              aria-label="Table navigation"> | ||||
|             <span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span>—<span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span> | ||||
|             <ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8"> | ||||
|                 <li> | ||||
|                     {% if page_obj.has_previous %} | ||||
|                         <a href="?{% param_replace page=page_obj.previous_page_number %}" | ||||
|                            class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Previous</a> | ||||
|                     {% else %} | ||||
|                         <a aria-current="page" | ||||
|                            class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a> | ||||
|                     {% endif %} | ||||
|                     {% for page in elided_page_range %} | ||||
|                         <li> | ||||
|                             {% if page != page_obj.number %} | ||||
|                                 <a href="?{% param_replace page=page %}" | ||||
|                                    class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">{{ page }}</a> | ||||
|                             {% else %} | ||||
|                                 <a aria-current="page" | ||||
|                                    class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-200">{{ page }}</a> | ||||
|                             {% endif %} | ||||
|                         </li> | ||||
|                     {% endfor %} | ||||
|                     {% if page_obj.has_next %} | ||||
|                         <a href="?{% param_replace page=page_obj.next_page_number %}" | ||||
|                            class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Next</a> | ||||
|                     {% else %} | ||||
|                         <a aria-current="page" | ||||
|                            class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a> | ||||
|                     {% endif %} | ||||
|                 </li> | ||||
|             </ul> | ||||
|         </nav> | ||||
|     {% endif %} | ||||
| </div> | ||||
							
								
								
									
										12
									
								
								games/templates/cotton/table.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								games/templates/cotton/table.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| <div class="relative overflow-x-auto shadow-md sm:rounded-lg"> | ||||
|     <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> | ||||
|         <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> | ||||
|             <tr> | ||||
|                 {% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %} | ||||
|             </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|             {{ slot }} | ||||
|         </tbody> | ||||
|     </table> | ||||
| </div> | ||||
							
								
								
									
										3
									
								
								games/templates/cotton/table_header.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								games/templates/cotton/table_header.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| <caption class="p-2 text-lg font-semibold rtl:text-left text-right text-gray-900 bg-white dark:text-white dark:bg-gray-900"> | ||||
|     {{ slot }} | ||||
| </caption> | ||||
							
								
								
									
										16
									
								
								games/templates/cotton/table_row.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								games/templates/cotton/table_row.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| <tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border-b [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2"> | ||||
|     {% if slot %} | ||||
|         {{ slot }} | ||||
|     {% else %} | ||||
|         {% for td in data %} | ||||
|             {% if forloop.first %} | ||||
|                 <th scope="row" | ||||
|                     class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th> | ||||
|             {% else %} | ||||
|                 <c-table-td> | ||||
|                 {{ td }} | ||||
|                 </c-table-td> | ||||
|             {% endif %} | ||||
|         {% endfor %} | ||||
|     {% endif %} | ||||
| </tr> | ||||
							
								
								
									
										1
									
								
								games/templates/cotton/table_td.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								games/templates/cotton/table_td.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| <td class="px-6 py-4 min-w-20-char max-w-20-char">{{ slot }}</td> | ||||
							
								
								
									
										10
									
								
								games/templates/list_purchases.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								games/templates/list_purchases.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| {% extends "base.html" %} | ||||
| {% load static %} | ||||
| {% block title %} | ||||
|     {{ title }} | ||||
| {% endblock title %} | ||||
| {% block content %} | ||||
|     <div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center"> | ||||
|         <c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range /> | ||||
|     </div> | ||||
| {% endblock content %} | ||||
| @ -1,4 +1,4 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% extends "base.html" %} | ||||
| {% load static %} | ||||
| {% block title %} | ||||
|     {{ title }} | ||||
| @ -15,7 +15,7 @@ | ||||
|                    hx-target=".responsive-table tbody" | ||||
|                    onClick="document.querySelector('#last-session-start').classList.add('invisible')" | ||||
|                    class="{% if last.timestamp_end == null %}invisible{% endif %}"> | ||||
|                     {% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %} | ||||
|                     {% include "components/button_start.html" with text=last.purchase title="Start session of last played game" only %} | ||||
|                 </a> | ||||
|             </div> | ||||
|         {% endif %} | ||||
| @ -32,42 +32,43 @@ | ||||
|                 <tbody> | ||||
|                     {% for session in dataset %} | ||||
|                         {% partialdef session-row inline=True %} | ||||
|                             <tr> | ||||
|                                 <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 }} | ||||
|                                         </a> | ||||
|                                     </span> | ||||
|                                 </td> | ||||
|                                 <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> | ||||
|                                     {{ session.timestamp_start | date:"d/m/Y H:i" }} | ||||
|                                 </td> | ||||
|                                 <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> | ||||
|                                     {% if not session.timestamp_end %} | ||||
|                                         {% url 'list_sessions_end_session' session.id as end_session_url %} | ||||
|                                         <a  href="{{ end_session_url }}" | ||||
|                                            hx-get="{{ end_session_url }}" | ||||
|                                            hx-target="closest tr" | ||||
|                                            hx-swap="outerHTML" | ||||
|                                            hx-indicator="#indicator" | ||||
|                                            onClick="document.querySelector('#last-session-start').classList.remove('invisible')"> | ||||
|                                             <span class="text-yellow-300">Finish now?</span> | ||||
|                                         </a> | ||||
|                                     {% elif session.duration_manual %} | ||||
|                                         -- | ||||
|                                     {% else %} | ||||
|                                         {{ session.timestamp_end | date:"d/m/Y H:i" }} | ||||
|                                     {% endif %} | ||||
|                                 </td> | ||||
|                                 <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td> | ||||
|                             </tr> | ||||
|                         {% endpartialdef %} | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         {% else %} | ||||
|             <div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|                         <tr> | ||||
|                             <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 }} | ||||
|                                     </a> | ||||
|                                 </span> | ||||
|                             </td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> | ||||
|                                 {{ session.timestamp_start | date:"d/m/Y H:i" }} | ||||
|                             </td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> | ||||
|                                 {% if not session.timestamp_end %} | ||||
|                                     {% url 'list_sessions_end_session' session.id as end_session_url %} | ||||
|                                     <a href="{{ end_session_url }}" | ||||
|                                        hx-get="{{ end_session_url }}" | ||||
|                                        hx-target="closest tr" | ||||
|                                        hx-swap="outerHTML" | ||||
|                                        hx-indicator="#indicator" | ||||
|                                        onClick="document.querySelector('#last-session-start').classList.remove('invisible')"> | ||||
|                                         <span class="text-yellow-300">Finish now?</span> | ||||
|                                     </a> | ||||
|                                 {% elif session.duration_manual %} | ||||
|                                     -- | ||||
|                                 {% else %} | ||||
|                                     {{ session.timestamp_end | date:"d/m/Y H:i" }} | ||||
|                                 {% endif %} | ||||
|                             </td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td> | ||||
|                         </tr> | ||||
|                     {% endpartialdef %} | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|     {% else %} | ||||
|         <div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div> | ||||
|     {% endif %} | ||||
| </div> | ||||
| {% endblock content %} | ||||
|  | ||||
							
								
								
									
										136
									
								
								games/templates/navbar.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								games/templates/navbar.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,136 @@ | ||||
| {% load static %} | ||||
| <nav class="bg-white border-gray-200 dark:bg-gray-900 dark:border-gray-700"> | ||||
|     <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> | ||||
|         <a href="{% url 'index' %}" | ||||
|            class="flex items-center space-x-3 rtl:space-x-reverse"> | ||||
|             <img src="{% static 'icons/schedule.png' %}" | ||||
|                  height="48" | ||||
|                  width="48" | ||||
|                  alt="Timetracker Logo" | ||||
|                  class="mr-4" /> | ||||
|             <span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span> | ||||
|         </a> | ||||
|         <button data-collapse-toggle="navbar-dropdown" | ||||
|                 type="button" | ||||
|                 class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" | ||||
|                 aria-controls="navbar-dropdown" | ||||
|                 aria-expanded="false"> | ||||
|             <span class="sr-only">Open main menu</span> | ||||
|             <svg class="w-5 h-5" | ||||
|                  aria-hidden="true" | ||||
|                  xmlns="http://www.w3.org/2000/svg" | ||||
|                  fill="none" | ||||
|                  viewBox="0 0 17 14"> | ||||
|                 <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" /> | ||||
|             </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"> | ||||
|                 <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" | ||||
|                        aria-current="page">Home</a> | ||||
|                 </li> | ||||
|                 <li> | ||||
|                     <button id="dropdownNavbarNewLink" | ||||
|                             data-dropdown-toggle="dropdownNavbarNew" | ||||
|                             class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent"> | ||||
|                         New | ||||
|                         <svg class="w-2.5 h-2.5 ms-2.5" | ||||
|                              aria-hidden="true" | ||||
|                              xmlns="http://www.w3.org/2000/svg" | ||||
|                              fill="none" | ||||
|                              viewBox="0 0 10 6"> | ||||
|                             <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" /> | ||||
|                         </svg> | ||||
|                     </button> | ||||
|                     <!-- Dropdown menu --> | ||||
|                     <div id="dropdownNavbarNew" | ||||
|                          class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600"> | ||||
|                         <ul class="py-2 text-sm text-gray-700 dark:text-gray-400" | ||||
|                             aria-labelledby="dropdownLargeButton"> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'add_device' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <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> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'add_purchase' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'add_session' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a> | ||||
|                             </li> | ||||
|                         </ul> | ||||
|                     </div> | ||||
|                 </li> | ||||
|                 <li> | ||||
|                     <button id="dropdownNavbarManageLink" | ||||
|                             data-dropdown-toggle="dropdownNavbarManage" | ||||
|                             class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent"> | ||||
|                         Manage | ||||
|                         <svg class="w-2.5 h-2.5 ms-2.5" | ||||
|                              aria-hidden="true" | ||||
|                              xmlns="http://www.w3.org/2000/svg" | ||||
|                              fill="none" | ||||
|                              viewBox="0 0 10 6"> | ||||
|                             <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" /> | ||||
|                         </svg> | ||||
|                     </button> | ||||
|                     <!-- Dropdown menu --> | ||||
|                     <div id="dropdownNavbarManage" | ||||
|                          class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600"> | ||||
|                         <ul class="py-2 text-sm text-gray-700 dark:text-gray-400" | ||||
|                             aria-labelledby="dropdownLargeButton"> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_devices' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_games' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_editions' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_platforms' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_purchases' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a> | ||||
|                             </li> | ||||
|                             <li> | ||||
|                                 <a href="{% url 'list_sessions' %}" | ||||
|                                    class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a> | ||||
|                             </li> | ||||
|                         </ul> | ||||
|                     </div> | ||||
|                 </li> | ||||
|                 <li> | ||||
|                     <a href="{% url 'stats_by_year' 0 %}" | ||||
|                        class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a> | ||||
|                 </li> | ||||
|                 <li> | ||||
|                     <a href="{% url 'logout' %}" | ||||
|                        class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log | ||||
|                     out</a> | ||||
|                 </li> | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
| </nav> | ||||
| @ -1,6 +1,5 @@ | ||||
| {% extends "base.html" %} | ||||
| {% load static %} | ||||
|  | ||||
| {% block title %} | ||||
|     Login | ||||
| {% endblock title %} | ||||
|  | ||||
| @ -3,17 +3,15 @@ | ||||
|     {{ title }} | ||||
| {% endblock title %} | ||||
| {% load static %} | ||||
|  | ||||
| {% partialdef purchase-name %} | ||||
|     {% if purchase.type != 'game' %} | ||||
|         {% #gamelink game_id=purchase.edition.game.id %} | ||||
|             {{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }}) | ||||
|         {% /gamelink %} | ||||
|     {% else %} | ||||
|         {% gamelink game_id=purchase.edition.game.id name=purchase.edition.name %} | ||||
|     {% endif %} | ||||
| {% if purchase.type != 'game' %} | ||||
|     <c-gamelink :game_id=purchase.edition.game.id> | ||||
|     {{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }}) | ||||
|     </c-gamelink> | ||||
| {% else %} | ||||
|     <c-gamelink :game_id=purchase.edition.game.id name=purchase.edition.name /> | ||||
| {% endif %} | ||||
| {% endpartialdef %} | ||||
|  | ||||
| {% block content %} | ||||
|     <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> | ||||
|         <div class="flex justify-center items-center"> | ||||
| @ -44,59 +42,71 @@ | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td> | ||||
|                 </tr> | ||||
|                 {% if total_games %} | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td> | ||||
|                     </tr> | ||||
|                 {% 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> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td> | ||||
|                 </tr> | ||||
|                 {% if all_finished_this_year_count %} | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td> | ||||
|                     </tr> | ||||
|                 {% endif %} | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ longest_session_time }} ({% gamelink game_id=longest_session_game.id name=longest_session_game.name %})</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                         {{ longest_session_time }} (<c-gamelink :game_id=longest_session_game.id :name=longest_session_game.name />) | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ highest_session_count }} ({% gamelink game_id=highest_session_count_game.id name=highest_session_count_game.name %})</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                         {{ highest_session_count }} (<c-gamelink :game_id=highest_session_count_game.id :name=highest_session_count_game.name />) | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                         {{ highest_session_average }} ({% gamelink game_id=highest_session_average_game.id name=highest_session_average_game.name %}) | ||||
|                         {{ highest_session_average }} (<c-gamelink :game_id=highest_session_average_game.id :name=highest_session_average_game.name />) | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% gamelink game_id=first_play_game.id name=first_play_game.name %} ({{ first_play_date }})</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                         <c-gamelink :game_id=first_play_game.id :name=first_play_game.name /> ({{ first_play_date }}) | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% gamelink game_id=last_play_game.id name=last_play_game.name %} ({{ last_play_date }})</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                         <c-gamelink :game_id=last_play_game.id :name=last_play_game.name /> ({{ last_play_date }}) | ||||
|                     </td> | ||||
|                 </tr> | ||||
|             </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <h1 class="text-5xl text-center my-6">Playtime per month</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <tbody> | ||||
|                 {% for month in month_playtimes %} | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|  | ||||
|         {% if month_playtime %} | ||||
|             <h1 class="text-5xl text-center my-6">Playtime per month</h1> | ||||
|             <table class="responsive-table"> | ||||
|                 <tbody> | ||||
|                     {% for month in month_playtimes %} | ||||
|                         <tr> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td> | ||||
|                         </tr> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         {% endif %} | ||||
|         <h1 class="text-5xl text-center my-6">Purchases</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <tbody> | ||||
| @ -112,9 +122,7 @@ | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                         {{ dropped_count }} ({{ dropped_percentage }}%) | ||||
|                     </td> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ dropped_count }} ({{ dropped_percentage }}%)</td> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td> | ||||
| @ -144,7 +152,7 @@ | ||||
|                 {% for game in top_10_games_by_playtime %} | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                             {% gamelink game_id=game.id name=game.name %} | ||||
|                             <c-gamelink :game_id=game.id :name=game.name /> | ||||
|                         </td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> | ||||
|                     </tr> | ||||
| @ -168,106 +176,104 @@ | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|         <h1 class="text-5xl text-center my-6">Finished</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for purchase in all_finished_this_year %} | ||||
|         {% if all_finished_this_year %} | ||||
|             <h1 class="text-5xl text-center my-6">Finished</h1> | ||||
|             <table class="responsive-table"> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                             {% partial purchase-name %} | ||||
|                         </td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|         <h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for purchase in this_year_finished_this_year %} | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     {% for purchase in all_finished_this_year %} | ||||
|                         <tr> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> | ||||
|                         </tr> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         {% endif %} | ||||
|         {% if this_year_finished_this_year %} | ||||
|             <h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1> | ||||
|             <table class="responsive-table"> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                             {% partial purchase-name %} | ||||
|                         </td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|         <h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for purchase in purchased_this_year_finished_this_year %} | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     {% for purchase in this_year_finished_this_year %} | ||||
|                         <tr> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> | ||||
|                         </tr> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         {% endif %} | ||||
|         {% if purchased_this_year_finished_this_year %} | ||||
|             <h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1> | ||||
|             <table class="responsive-table"> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                             {% partial purchase-name %} | ||||
|                         </td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <h1 class="text-5xl text-center my-6">Unfinished Purchases</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for purchase in purchased_unfinished %} | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     {% for purchase in purchased_this_year_finished_this_year %} | ||||
|                         <tr> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> | ||||
|                         </tr> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         {% endif %} | ||||
|         {% if purchased_unfinished %} | ||||
|             <h1 class="text-5xl text-center my-6">Unfinished Purchases</h1> | ||||
|             <table class="responsive-table"> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                             {% partial purchase-name %} | ||||
|                         </td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.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> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <h1 class="text-5xl text-center my-6">All Purchases</h1> | ||||
|         <table class="responsive-table"> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th> | ||||
|                     <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for purchase in all_purchased_this_year %} | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     {% for purchase in purchased_unfinished %} | ||||
|                         <tr> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.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> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         {% endif %} | ||||
|         {% if all_purchased_this_year %} | ||||
|             <h1 class="text-5xl text-center my-6">All Purchases</h1> | ||||
|             <table class="responsive-table"> | ||||
|                 <thead> | ||||
|                     <tr> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> | ||||
|                             {% partial purchase-name %} | ||||
|                         </td> | ||||
|                         <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.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> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th> | ||||
|                         <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     {% for purchase in all_purchased_this_year %} | ||||
|                         <tr> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> | ||||
|                             <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.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> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         {% endif %} | ||||
|     </div> | ||||
| {% endblock content %} | ||||
|  | ||||
| @ -10,151 +10,98 @@ | ||||
|             <div class="flex gap-5 mb-3"> | ||||
|                 <span class="text-wrap max-w-80 text-4xl"> | ||||
|                     <span class="font-bold font-serif">{{ game.name }}</span> <span data-popover-target="popover-year" class="text-slate-500 text-2xl">{{ game.year_released }}</span> | ||||
|                         {% #popover id="popover-year" %} | ||||
|                             Original release year | ||||
|                         {% /popover %} | ||||
|                     <c-popover id="popover-year"> | ||||
|                     Original release year | ||||
|                     </c-popover> | ||||
|                 </span> | ||||
|             </div> | ||||
|             <div class="flex gap-4 dark:text-slate-400 mb-3"> | ||||
|                 <span data-popover-target="popover-hours" class="flex gap-2 items-center"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" | ||||
|                          fill="none" | ||||
|                          viewBox="0 0 24 24" | ||||
|                          stroke-width="1.5" | ||||
|                          stroke="currentColor" | ||||
|                          class="size-6"> | ||||
|                         <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> | ||||
|                     </svg> | ||||
|                     {{ hours_sum }} | ||||
|                     {% #popover id="popover-hours" %} | ||||
|                     <c-popover id="popover-hours"> | ||||
|                     Total hours played | ||||
|                     {% /popover %} | ||||
|                     </c-popover> | ||||
|                 </span> | ||||
|                 <span data-popover-target="popover-sessions" class="flex gap-2 items-center"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> | ||||
|                 <span data-popover-target="popover-sessions" | ||||
|                       class="flex gap-2 items-center"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" | ||||
|                          fill="none" | ||||
|                          viewBox="0 0 24 24" | ||||
|                          stroke-width="1.5" | ||||
|                          stroke="currentColor" | ||||
|                          class="size-6"> | ||||
|                         <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" /> | ||||
|                     </svg> | ||||
|                     {{ session_count }} | ||||
|                     {% #popover id="popover-sessions" %} | ||||
|                     <c-popover id="popover-sessions"> | ||||
|                     Number of sessions | ||||
|                     {% /popover %} | ||||
|                     </c-popover> | ||||
|                 </span> | ||||
|                 <span data-popover-target="popover-average" class="flex gap-2 items-center"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" | ||||
|                          fill="none" | ||||
|                          viewBox="0 0 24 24" | ||||
|                          stroke-width="1.5" | ||||
|                          stroke="currentColor" | ||||
|                          class="size-6"> | ||||
|                         <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /> | ||||
|                     </svg> | ||||
|                     {{ session_average_without_manual }} | ||||
|                     {% #popover id="popover-average" %} | ||||
|                     <c-popover id="popover-average"> | ||||
|                     Average playtime per session | ||||
|                     {% /popover %} | ||||
|                     </c-popover> | ||||
|                 </span> | ||||
|                 <span data-popover-target="popover-playrange" class="flex gap-2 items-center"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> | ||||
|                 <span data-popover-target="popover-playrange" | ||||
|                       class="flex gap-2 items-center"> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" | ||||
|                          fill="none" | ||||
|                          viewBox="0 0 24 24" | ||||
|                          stroke-width="1.5" | ||||
|                          stroke="currentColor" | ||||
|                          class="size-6"> | ||||
|                         <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /> | ||||
|                     </svg> | ||||
|                     {{ playrange }} | ||||
|                     {% #popover id="popover-playrange" %} | ||||
|                     <c-popover id="popover-playrange"> | ||||
|                     Earliest and latest dates played | ||||
|                     {% /popover %} | ||||
|                     </c-popover> | ||||
|                 </span> | ||||
|             </div> | ||||
|             <div class="inline-flex rounded-md shadow-sm mb-3" role="group"> | ||||
|                 <a href="{% url 'edit_game' game.id %}"> | ||||
|                     <button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> | ||||
|                     Edit | ||||
|                     <button type="button" | ||||
|                             class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> | ||||
|                         Edit | ||||
|                     </button> | ||||
|                 </a> | ||||
|                 <a href="{% url 'delete_game' game.id %}"> | ||||
|                     <button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> | ||||
|                     Delete | ||||
|                     <button type="button" | ||||
|                             class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> | ||||
|                         Delete | ||||
|                     </button> | ||||
|                 </a> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <h1 class="text-3xl mt-4 mb-1 font-condensed"> | ||||
|             Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span> | ||||
|         </h1> | ||||
|         <ul> | ||||
|             {% for edition in editions %} | ||||
|                 <li class="sm:pl-2 flex items-center"> | ||||
|                     {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }}) | ||||
|                     {% if edition.wikidata %} | ||||
|                         <span class="hidden sm:inline"> | ||||
|                             <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}"> | ||||
|                                 <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" /> | ||||
|                             </a> | ||||
|                         </span> | ||||
|                     {% endif %} | ||||
|                     {% url 'edit_edition' edition.id as edit_url %} | ||||
|                     {% include 'components/edit_button.html' with edit_url=edit_url %} | ||||
|                 </li> | ||||
|                 <ul> | ||||
|                     {% for purchase in edition.game_purchases %} | ||||
|                         <li class="sm:pl-6 flex items-center {% if purchase.date_refunded %}text-red-600{% endif %}"> | ||||
|                             {{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }} | ||||
|                             {% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %} | ||||
|                             {% url 'edit_purchase' purchase.id as edit_url %} | ||||
|                             {% include 'components/edit_button.html' with edit_url=edit_url %} | ||||
|                         </li> | ||||
|                         <ul> | ||||
|                             {% for related_purchase in purchase.nongame_related_purchases %} | ||||
|                                 <li class="sm:pl-12 flex items-center"> | ||||
|                                     {{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ 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> | ||||
|                     {% endfor %} | ||||
|                 </ul> | ||||
|             {% endfor %} | ||||
|         </ul> | ||||
|         <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center font-condensed"> | ||||
|             Sessions | ||||
|             <span class="dark:text-slate-500" id="session-count">({{ session_count }})</span> | ||||
| {% if latest_session_id %} | ||||
|             {% url 'view_game_start_session_from_session' latest_session_id as add_session_link %} | ||||
|             <a | ||||
|                 class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm" | ||||
|                 title="Start new session" | ||||
|                 href="{{ add_session_link }}" | ||||
|                 hx-get="{{ add_session_link }}" | ||||
|                 hx-vals="js:{session_count:getSessionCount()}" | ||||
|                 hx-target="#session-list" | ||||
|                 hx-swap="afterbegin" | ||||
|             >New</a> | ||||
| {% endif %} | ||||
|             and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span> | ||||
|         </h1> | ||||
|         <ul id="session-list"> | ||||
|             {% for session in sessions %} | ||||
|                 {% partialdef session-info inline=True %} | ||||
|                     <li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1"> | ||||
|                         {{ session.timestamp_start | date:"d/m/Y H:i" }}{% if session.timestamp_end %}-{{ session.timestamp_end | date:"H:i" }}{% endif %} | ||||
|                         ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }}) | ||||
|                         {% url 'edit_session' session.id as edit_url %} | ||||
|                         {% include 'components/edit_button.html' with edit_url=edit_url %} | ||||
|                         {% if not session.timestamp_end %} | ||||
|                             {% url 'view_game_end_session' session.id as end_session_url %} | ||||
|                             <a | ||||
|                                 class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center" | ||||
|                                 href="{{ end_session_url }}" | ||||
|                                 hx-get="{{ end_session_url }}" | ||||
|                                 hx-target="closest li" | ||||
|                                 hx-swap="outerHTML" | ||||
|                                 hx-vals="js:{session_count:getSessionCount()}" | ||||
|                                 hx-indicator="#indicator" | ||||
|                             > | ||||
|                                 <svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" class="h-3" x="0px" y="0px" viewBox="0 0 24 24"> | ||||
|                                     <path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z"></path> | ||||
|                                 </svg> | ||||
|                             </a> | ||||
|  | ||||
|                         {% endif %} | ||||
|                     </li> | ||||
|                     <li class="sm:pl-4 markdown-content">{{ session.note|markdown }}</li> | ||||
|                     <div class="hidden" hx-swap-oob="innerHTML:#session-count"> | ||||
|                         ({{ session_count }}) | ||||
|                     </div> | ||||
|                 {% endpartialdef %} | ||||
|             {% endfor %} | ||||
|         </ul> | ||||
|         <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> | ||||
|             <c-simple-table :rows=purchase_data.rows :columns=purchase_data.columns /> | ||||
|         </div> | ||||
|         <div class="mb-6"> | ||||
|             <c-h1 :badge=session_count>Sessions</c-h1> | ||||
|             <c-simple-table :rows=session_data.rows :columns=session_data.columns :page_obj=session_page_obj :elided_page_range=session_elided_page_range /> | ||||
|         </div> | ||||
|     </div> | ||||
|     <script> | ||||
|         function getSessionCount() { | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import markdown | ||||
| from django import template | ||||
| from django.utils.safestring import mark_safe | ||||
| import markdown | ||||
|  | ||||
| register = template.Library() | ||||
|  | ||||
|  | ||||
							
								
								
									
										18
									
								
								games/templatetags/param_utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								games/templatetags/param_utils.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| from typing import Any | ||||
|  | ||||
| from django import template | ||||
| from django.http import QueryDict | ||||
|  | ||||
| register = template.Library() | ||||
|  | ||||
|  | ||||
| @register.simple_tag(takes_context=True) | ||||
| def param_replace(context: dict[Any, Any], **kwargs): | ||||
|     """ | ||||
|     Return encoded URL parameters that are the same as the current | ||||
|     request's parameters, only with the specified GET parameters added or changed. | ||||
|     """ | ||||
|     d: QueryDict = context["request"].GET.copy() | ||||
|     for k, v in kwargs.items(): | ||||
|         d[k] = v | ||||
|     return d.urlencode() | ||||
							
								
								
									
										11
									
								
								games/templatetags/randomid.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								games/templatetags/randomid.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| import random | ||||
| import string | ||||
|  | ||||
| from django import template | ||||
|  | ||||
| register = template.Library() | ||||
|  | ||||
|  | ||||
| @register.simple_tag | ||||
| def randomid(seed: str = "") -> str: | ||||
|     return str(hash(seed + "".join(random.choices(string.ascii_lowercase, k=10)))) | ||||
							
								
								
									
										121
									
								
								games/urls.py
									
									
									
									
									
								
							
							
						
						
									
										121
									
								
								games/urls.py
									
									
									
									
									
								
							| @ -1,117 +1,110 @@ | ||||
| from django.urls import path | ||||
|  | ||||
| from games import views | ||||
| from games.views import device, edition, game, general, platform, purchase, session | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("", views.index, name="index"), | ||||
|     path("device/add", views.add_device, name="add_device"), | ||||
|     path("edition/add", views.add_edition, name="add_edition"), | ||||
|     path("", general.index, name="index"), | ||||
|     path("device/add", device.add_device, name="add_device"), | ||||
|     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>", | ||||
|         views.add_edition, | ||||
|         edition.add_edition, | ||||
|         name="add_edition_for_game", | ||||
|     ), | ||||
|     path("edition/<int:edition_id>/edit", views.edit_edition, name="edit_edition"), | ||||
|     path("game/add", views.add_game, name="add_game"), | ||||
|     path("game/<int:game_id>/edit", views.edit_game, name="edit_game"), | ||||
|     path("game/<int:game_id>/view", views.view_game, name="view_game"), | ||||
|     path("game/<int:game_id>/delete", views.delete_game, name="delete_game"), | ||||
|     path("platform/add", views.add_platform, name="add_platform"), | ||||
|     path("platform/<int:platform_id>/edit", views.edit_platform, name="edit_platform"), | ||||
|     path("purchase/add", views.add_purchase, name="add_purchase"), | ||||
|     path("purchase/<int:purchase_id>/edit", views.edit_purchase, name="edit_purchase"), | ||||
|     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"), | ||||
|     path("game/<int:game_id>/delete", game.delete_game, name="delete_game"), | ||||
|     path("game/list", game.list_games, name="list_games"), | ||||
|     path("platform/add", platform.add_platform, name="add_platform"), | ||||
|     path( | ||||
|         "platform/<int:platform_id>/edit", | ||||
|         platform.edit_platform, | ||||
|         name="edit_platform", | ||||
|     ), | ||||
|     path( | ||||
|         "platform/<int:platform_id>/delete", | ||||
|         platform.delete_platform, | ||||
|         name="delete_platform", | ||||
|     ), | ||||
|     path("platform/list", platform.list_platforms, name="list_platforms"), | ||||
|     path("purchase/add", purchase.add_purchase, name="add_purchase"), | ||||
|     path( | ||||
|         "purchase/<int:purchase_id>/edit", | ||||
|         purchase.edit_purchase, | ||||
|         name="edit_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "purchase/<int:purchase_id>/delete", | ||||
|         views.delete_purchase, | ||||
|         purchase.delete_purchase, | ||||
|         name="delete_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "purchase/list", | ||||
|         purchase.list_purchases, | ||||
|         name="list_purchases", | ||||
|     ), | ||||
|     path( | ||||
|         "purchase/related-purchase-by-edition", | ||||
|         views.related_purchase_by_edition, | ||||
|         purchase.related_purchase_by_edition, | ||||
|         name="related_purchase_by_edition", | ||||
|     ), | ||||
|     path( | ||||
|         "purchase/add/for-edition/<int:edition_id>", | ||||
|         views.add_purchase, | ||||
|         purchase.add_purchase, | ||||
|         name="add_purchase_for_edition", | ||||
|     ), | ||||
|     path("session/add", views.add_session, name="add_session"), | ||||
|     path("session/add", session.add_session, name="add_session"), | ||||
|     path( | ||||
|         "session/add/for-purchase/<int:purchase_id>", | ||||
|         views.add_session, | ||||
|         session.add_session, | ||||
|         name="add_session_for_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "session/add/from-game/<int:session_id>", | ||||
|         views.new_session_from_existing_session, | ||||
|         session.new_session_from_existing_session, | ||||
|         {"template": "view_game.html#session-info"}, | ||||
|         name="view_game_start_session_from_session", | ||||
|     ), | ||||
|     path( | ||||
|         "session/add/from-list/<int:session_id>", | ||||
|         views.new_session_from_existing_session, | ||||
|         session.new_session_from_existing_session, | ||||
|         {"template": "list_sessions.html#session-row"}, | ||||
|         name="list_sessions_start_session_from_session", | ||||
|     ), | ||||
|     path("session/<int:session_id>/edit", views.edit_session, name="edit_session"), | ||||
|     path("session/<int:session_id>/edit", session.edit_session, name="edit_session"), | ||||
|     path( | ||||
|         "session/<int:session_id>/delete", | ||||
|         views.delete_session, | ||||
|         session.delete_session, | ||||
|         name="delete_session", | ||||
|     ), | ||||
|     path( | ||||
|         "session/end/from-game/<int:session_id>", | ||||
|         views.end_session, | ||||
|         session.end_session, | ||||
|         {"template": "view_game.html#session-info"}, | ||||
|         name="view_game_end_session", | ||||
|     ), | ||||
|     path( | ||||
|         "session/end/from-list/<int:session_id>", | ||||
|         views.end_session, | ||||
|         session.end_session, | ||||
|         {"template": "list_sessions.html#session-row"}, | ||||
|         name="list_sessions_end_session", | ||||
|     ), | ||||
|     path("session/list", views.list_sessions, name="list_sessions"), | ||||
|     path( | ||||
|         "session/list/recent", | ||||
|         views.list_sessions, | ||||
|         {"filter": "recent"}, | ||||
|         name="list_sessions_recent", | ||||
|     ), | ||||
|     path( | ||||
|         "session/list/by-purchase/<int:purchase_id>", | ||||
|         views.list_sessions, | ||||
|         {"filter": "purchase"}, | ||||
|         name="list_sessions_by_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "session/list/by-platform/<int:platform_id>", | ||||
|         views.list_sessions, | ||||
|         {"filter": "platform"}, | ||||
|         name="list_sessions_by_platform", | ||||
|     ), | ||||
|     path( | ||||
|         "session/list/by-game/<int:game_id>", | ||||
|         views.list_sessions, | ||||
|         {"filter": "game"}, | ||||
|         name="list_sessions_by_game", | ||||
|     ), | ||||
|     path( | ||||
|         "session/list/by-edition/<int:edition_id>", | ||||
|         views.list_sessions, | ||||
|         {"filter": "edition"}, | ||||
|         name="list_sessions_by_edition", | ||||
|     ), | ||||
|     path( | ||||
|         "session/list/by-ownership/<str:ownership_type>", | ||||
|         views.list_sessions, | ||||
|         {"filter": "ownership_type"}, | ||||
|         name="list_sessions_by_ownership_type", | ||||
|     ), | ||||
|     path("stats/", views.stats, name="stats_current_year"), | ||||
|     path("session/list", session.list_sessions, name="list_sessions"), | ||||
|     path("stats/", general.stats_alltime, name="stats_alltime"), | ||||
|     path( | ||||
|         "stats/<int:year>", | ||||
|         views.stats, | ||||
|         general.stats, | ||||
|         name="stats_by_year", | ||||
|     ), | ||||
| ] | ||||
|  | ||||
							
								
								
									
										743
									
								
								games/views.py
									
									
									
									
									
								
							
							
						
						
									
										743
									
								
								games/views.py
									
									
									
									
									
								
							| @ -1,743 +0,0 @@ | ||||
| from datetime import datetime | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
|  | ||||
| from django.db.models import ( | ||||
|     Avg, | ||||
|     Count, | ||||
|     ExpressionWrapper, | ||||
|     F, | ||||
|     Prefetch, | ||||
|     Q, | ||||
|     Sum, | ||||
|     fields, | ||||
|     IntegerField, | ||||
| ) | ||||
| from django.db.models.functions import TruncDate, ExtractMonth, TruncMonth | ||||
| from django.http import ( | ||||
|     HttpRequest, | ||||
|     HttpResponse, | ||||
|     HttpResponseBadRequest, | ||||
|     HttpResponseRedirect, | ||||
| ) | ||||
| from django.shortcuts import redirect, render | ||||
| from django.urls import reverse | ||||
| from django.utils import timezone | ||||
| from django.shortcuts import get_object_or_404 | ||||
|  | ||||
| from common.time import format_duration | ||||
| from common.utils import safe_division, safe_getattr | ||||
|  | ||||
| from .forms import ( | ||||
|     DeviceForm, | ||||
|     EditionForm, | ||||
|     GameForm, | ||||
|     PlatformForm, | ||||
|     PurchaseForm, | ||||
|     SessionForm, | ||||
| ) | ||||
| from .models import Edition, Game, Platform, Purchase, Session | ||||
|  | ||||
|  | ||||
| def model_counts(request): | ||||
|     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(), | ||||
|     } | ||||
|  | ||||
|  | ||||
| def stats_dropdown_year_range(request): | ||||
|     result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)} | ||||
|     return result | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_session(request, purchase_id=None): | ||||
|     context = {} | ||||
|     initial = {"timestamp_start": timezone.now()} | ||||
|  | ||||
|     last = Session.objects.last() | ||||
|     if last != None: | ||||
|         initial["purchase"] = last.purchase | ||||
|  | ||||
|     if request.method == "POST": | ||||
|         form = SessionForm(request.POST or None, initial=initial) | ||||
|         if form.is_valid(): | ||||
|             form.save() | ||||
|             return redirect("list_sessions") | ||||
|     else: | ||||
|         if purchase_id: | ||||
|             purchase = Purchase.objects.get(id=purchase_id) | ||||
|             form = SessionForm( | ||||
|                 initial={ | ||||
|                     **initial, | ||||
|                     "purchase": purchase, | ||||
|                 } | ||||
|             ) | ||||
|         else: | ||||
|             form = SessionForm(initial=initial) | ||||
|  | ||||
|     context["title"] = "Add New Session" | ||||
|     context["form"] = form | ||||
|     return render(request, "add_session.html", context) | ||||
|  | ||||
|  | ||||
| def use_custom_redirect( | ||||
|     func: Callable[..., HttpResponse] | ||||
| ) -> Callable[..., HttpResponse]: | ||||
|     """ | ||||
|     Will redirect to "return_path" session variable if set. | ||||
|     """ | ||||
|  | ||||
|     def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||
|         response = func(request, *args, **kwargs) | ||||
|         if isinstance(response, HttpResponseRedirect) and ( | ||||
|             next_url := request.session.get("return_path") | ||||
|         ): | ||||
|             return HttpResponseRedirect(next_url) | ||||
|         return response | ||||
|  | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_session(request, session_id=None): | ||||
|     context = {} | ||||
|     session = Session.objects.get(id=session_id) | ||||
|     form = SessionForm(request.POST or None, instance=session) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Session" | ||||
|     context["form"] = form | ||||
|     return render(request, "add_session.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_purchase(request, purchase_id=None): | ||||
|     context = {} | ||||
|     purchase = Purchase.objects.get(id=purchase_id) | ||||
|     form = PurchaseForm(request.POST or None, instance=purchase) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Purchase" | ||||
|     context["form"] = form | ||||
|     context["purchase_id"] = purchase_id | ||||
|     context["script_name"] = "add_purchase.js" | ||||
|     return render(request, "add_purchase.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_game(request, game_id=None): | ||||
|     context = {} | ||||
|     purchase = Game.objects.get(id=game_id) | ||||
|     form = GameForm(request.POST or None, instance=purchase) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Game" | ||||
|     context["form"] = form | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_game(request, game_id=None): | ||||
|     game = get_object_or_404(Game, id=game_id) | ||||
|     game.delete() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def view_game(request, game_id=None): | ||||
|     game = Game.objects.get(id=game_id) | ||||
|     nongame_related_purchases_prefetch = Prefetch( | ||||
|         "related_purchases", | ||||
|         queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by( | ||||
|             "date_purchased" | ||||
|         ), | ||||
|         to_attr="nongame_related_purchases", | ||||
|     ) | ||||
|     game_purchases_prefetch = Prefetch( | ||||
|         "purchase_set", | ||||
|         queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related( | ||||
|             nongame_related_purchases_prefetch | ||||
|         ), | ||||
|         to_attr="game_purchases", | ||||
|     ) | ||||
|     editions = ( | ||||
|         Edition.objects.filter(game=game) | ||||
|         .prefetch_related(game_purchases_prefetch) | ||||
|         .order_by("year_released") | ||||
|     ) | ||||
|  | ||||
|     sessions = Session.objects.prefetch_related("device").filter( | ||||
|         purchase__edition__game=game | ||||
|     ) | ||||
|     session_count = sessions.count() | ||||
|     session_count_without_manual = ( | ||||
|         Session.objects.without_manual().filter(purchase__edition__game=game).count() | ||||
|     ) | ||||
|  | ||||
|     if sessions: | ||||
|         playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y") | ||||
|         latest_session = sessions.latest() | ||||
|         playrange_end = latest_session.timestamp_start.strftime("%b %Y") | ||||
|  | ||||
|         playrange = ( | ||||
|             playrange_start | ||||
|             if playrange_start == playrange_end | ||||
|             else f"{playrange_start} — {playrange_end}" | ||||
|         ) | ||||
|     else: | ||||
|         playrange = "N/A" | ||||
|         latest_session = None | ||||
|  | ||||
|     total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H")) | ||||
|     total_hours_without_manual = float( | ||||
|         format_duration(sessions.calculated_duration_unformatted(), "%2.1H") | ||||
|     ) | ||||
|     context = { | ||||
|         "edition_count": editions.count(), | ||||
|         "editions": editions, | ||||
|         "game": game, | ||||
|         "playrange": playrange, | ||||
|         "purchase_count": Purchase.objects.filter(edition__game=game).count(), | ||||
|         "session_average_without_manual": round( | ||||
|             safe_division( | ||||
|                 total_hours_without_manual, int(session_count_without_manual) | ||||
|             ), | ||||
|             1, | ||||
|         ), | ||||
|         "session_count": session_count, | ||||
|         "sessions_with_notes_count": sessions.exclude(note="").count(), | ||||
|         "sessions": sessions.order_by("-timestamp_start"), | ||||
|         "title": f"Game Overview - {game.name}", | ||||
|         "hours_sum": total_hours, | ||||
|         "latest_session_id": safe_getattr(latest_session, "pk"), | ||||
|     } | ||||
|  | ||||
|     request.session["return_path"] = request.path | ||||
|     return render(request, "view_game.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_platform(request, platform_id=None): | ||||
|     context = {} | ||||
|     purchase = Platform.objects.get(id=platform_id) | ||||
|     form = PlatformForm(request.POST or None, instance=purchase) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Platform" | ||||
|     context["form"] = form | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_edition(request, edition_id=None): | ||||
|     context = {} | ||||
|     edition = Edition.objects.get(id=edition_id) | ||||
|     form = EditionForm(request.POST or None, instance=edition) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Edition" | ||||
|     context["form"] = form | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| def related_purchase_by_edition(request): | ||||
|     edition_id = request.GET.get("edition") | ||||
|     if not edition_id: | ||||
|         return HttpResponseBadRequest("Invalid edition_id") | ||||
|     form = PurchaseForm() | ||||
|     form.fields["related_purchase"].queryset = Purchase.objects.filter( | ||||
|         edition_id=edition_id, type=Purchase.GAME | ||||
|     ).order_by("edition__sort_name") | ||||
|     return render(request, "partials/related_purchase_field.html", {"form": form}) | ||||
|  | ||||
|  | ||||
| def clone_session_by_id(session_id: int) -> Session: | ||||
|     session = get_object_or_404(Session, id=session_id) | ||||
|     clone = session | ||||
|     clone.pk = None | ||||
|     clone.timestamp_start = timezone.now() | ||||
|     clone.timestamp_end = None | ||||
|     clone.note = "" | ||||
|     clone.save() | ||||
|     return clone | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def new_session_from_existing_session(request, session_id: int, template: str = ""): | ||||
|     session = clone_session_by_id(session_id) | ||||
|     if request.htmx: | ||||
|         context = { | ||||
|             "session": session, | ||||
|             "session_count": int(request.GET.get("session_count", 0)) + 1, | ||||
|         } | ||||
|         return render(request, template, context) | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def end_session(request, session_id: int, template: str = ""): | ||||
|     session = get_object_or_404(Session, id=session_id) | ||||
|     session.timestamp_end = timezone.now() | ||||
|     session.save() | ||||
|     if request.htmx: | ||||
|         context = { | ||||
|             "session": session, | ||||
|             "session_count": request.GET.get("session_count", 0), | ||||
|         } | ||||
|         return render(request, template, context) | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_session(request, session_id=None): | ||||
|     session = get_object_or_404(Session, id=session_id) | ||||
|     session.delete() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def list_sessions( | ||||
|     request, | ||||
|     filter="", | ||||
|     purchase_id="", | ||||
|     platform_id="", | ||||
|     game_id="", | ||||
|     edition_id="", | ||||
|     ownership_type: str = "", | ||||
| ): | ||||
|     context = {} | ||||
|     context["title"] = "Sessions" | ||||
|  | ||||
|     all_sessions = Session.objects.prefetch_related( | ||||
|         "purchase", "purchase__edition", "purchase__edition__game" | ||||
|     ).order_by("-timestamp_start") | ||||
|  | ||||
|     if filter == "purchase": | ||||
|         dataset = all_sessions.filter(purchase=purchase_id) | ||||
|         context["purchase"] = Purchase.objects.get(id=purchase_id) | ||||
|     elif filter == "platform": | ||||
|         dataset = all_sessions.filter(purchase__platform=platform_id) | ||||
|         context["platform"] = Platform.objects.get(id=platform_id) | ||||
|     elif filter == "edition": | ||||
|         dataset = all_sessions.filter(purchase__edition=edition_id) | ||||
|         context["edition"] = Edition.objects.get(id=edition_id) | ||||
|     elif filter == "game": | ||||
|         dataset = all_sessions.filter(purchase__edition__game=game_id) | ||||
|         context["game"] = Game.objects.get(id=game_id) | ||||
|     elif filter == "ownership_type": | ||||
|         dataset = all_sessions.filter(purchase__ownership_type=ownership_type) | ||||
|         context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] | ||||
|     elif filter == "recent": | ||||
|         current_year = timezone.now().year | ||||
|         first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1)) | ||||
|         dataset = all_sessions.filter(timestamp_start__gte=first_day_of_year).order_by( | ||||
|             "-timestamp_start" | ||||
|         ) | ||||
|         context["title"] = "This year" | ||||
|     else: | ||||
|         dataset = all_sessions | ||||
|  | ||||
|     context = { | ||||
|         **context, | ||||
|         "dataset": dataset, | ||||
|         "dataset_count": dataset.count(), | ||||
|         "last": Session.objects.prefetch_related("purchase__platform").latest(), | ||||
|     } | ||||
|  | ||||
|     return render(request, "list_sessions.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def stats(request, year: int = 0): | ||||
|     selected_year = request.GET.get("year") | ||||
|     if selected_year: | ||||
|         return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) | ||||
|     if year == 0: | ||||
|         year = timezone.now().year | ||||
|     this_year_sessions = Session.objects.filter( | ||||
|         timestamp_start__year=year | ||||
|     ).select_related("purchase__edition") | ||||
|     this_year_sessions_with_durations = this_year_sessions.annotate( | ||||
|         duration=ExpressionWrapper( | ||||
|             F("timestamp_end") - F("timestamp_start"), | ||||
|             output_field=fields.DurationField(), | ||||
|         ) | ||||
|     ) | ||||
|     longest_session = this_year_sessions_with_durations.order_by("-duration").first() | ||||
|     this_year_games = Game.objects.filter( | ||||
|         edition__purchase__session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|     this_year_games_with_session_counts = this_year_games.annotate( | ||||
|         session_count=Count( | ||||
|             "edition__purchase__session", | ||||
|             filter=Q(edition__purchase__session__timestamp_start__year=year), | ||||
|         ) | ||||
|     ) | ||||
|     game_highest_session_count = this_year_games_with_session_counts.order_by( | ||||
|         "-session_count" | ||||
|     ).first() | ||||
|     selected_currency = "CZK" | ||||
|     unique_days = ( | ||||
|         this_year_sessions.annotate(date=TruncDate("timestamp_start")) | ||||
|         .values("date") | ||||
|         .distinct() | ||||
|         .aggregate(dates=Count("date")) | ||||
|     ) | ||||
|     this_year_played_purchases = Purchase.objects.filter( | ||||
|         session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|  | ||||
|     this_year_purchases = Purchase.objects.filter(date_purchased__year=year) | ||||
|     this_year_purchases_with_currency = this_year_purchases.select_related( | ||||
|         "edition" | ||||
|     ).filter(price_currency__exact=selected_currency) | ||||
|     this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( | ||||
|         date_refunded=None | ||||
|     ) | ||||
|     this_year_purchases_refunded = this_year_purchases_with_currency.refunded() | ||||
|  | ||||
|     this_year_purchases_unfinished_dropped_nondropped = ( | ||||
|         this_year_purchases_without_refunded.filter(date_finished__isnull=True) | ||||
|         .filter(infinite=False) | ||||
|         .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) | ||||
|     )  # do not count battle passes etc. | ||||
|  | ||||
|     this_year_purchases_unfinished = ( | ||||
|         this_year_purchases_unfinished_dropped_nondropped.filter( | ||||
|             date_dropped__isnull=True | ||||
|         ) | ||||
|     ) | ||||
|     this_year_purchases_dropped = ( | ||||
|         this_year_purchases_unfinished_dropped_nondropped.filter( | ||||
|             date_dropped__isnull=False | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
|     this_year_purchases_without_refunded_count = ( | ||||
|         this_year_purchases_without_refunded.count() | ||||
|     ) | ||||
|     this_year_purchases_unfinished_count = this_year_purchases_unfinished.count() | ||||
|     this_year_purchases_unfinished_percent = int( | ||||
|         safe_division( | ||||
|             this_year_purchases_unfinished_count, | ||||
|             this_year_purchases_without_refunded_count, | ||||
|         ) | ||||
|         * 100 | ||||
|     ) | ||||
|  | ||||
|     purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) | ||||
|     purchases_finished_this_year_released_this_year = ( | ||||
|         purchases_finished_this_year.filter(edition__year_released=year).order_by( | ||||
|             "date_finished" | ||||
|         ) | ||||
|     ) | ||||
|     purchased_this_year_finished_this_year = ( | ||||
|         this_year_purchases_without_refunded.filter(date_finished__year=year) | ||||
|     ).order_by("date_finished") | ||||
|  | ||||
|     this_year_spendings = this_year_purchases_without_refunded.aggregate( | ||||
|         total_spent=Sum(F("price")) | ||||
|     ) | ||||
|     total_spent = this_year_spendings["total_spent"] or 0 | ||||
|  | ||||
|     games_with_playtime = ( | ||||
|         Game.objects.filter(edition__purchase__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             total_playtime=Sum( | ||||
|                 F("edition__purchase__session__duration_calculated") | ||||
|                 + F("edition__purchase__session__duration_manual") | ||||
|             ) | ||||
|         ) | ||||
|         .values("id", "name", "total_playtime") | ||||
|     ) | ||||
|     month_playtimes = ( | ||||
|         this_year_sessions.annotate(month=TruncMonth("timestamp_start")) | ||||
|         .values("month") | ||||
|         .annotate(playtime=Sum("duration_calculated")) | ||||
|         .order_by("month") | ||||
|     ) | ||||
|     for month in month_playtimes: | ||||
|         month["playtime"] = format_duration(month["playtime"], "%2.0H") | ||||
|  | ||||
|     highest_session_average_game = ( | ||||
|         Game.objects.filter(edition__purchase__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             session_average=Avg("edition__purchase__session__duration_calculated") | ||||
|         ) | ||||
|         .order_by("-session_average") | ||||
|         .first() | ||||
|     ) | ||||
|     top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] | ||||
|     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") | ||||
|         .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) | ||||
|         .annotate(platform_name=F("purchase__platform__name")) | ||||
|         .values("platform_name", "total_playtime") | ||||
|         .order_by("-total_playtime") | ||||
|     ) | ||||
|     for item in total_playtime_per_platform: | ||||
|         item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H") | ||||
|  | ||||
|     backlog_decrease_count = ( | ||||
|         Purchase.objects.filter(date_purchased__year__lt=year) | ||||
|         .intersection(purchases_finished_this_year) | ||||
|         .count() | ||||
|     ) | ||||
|  | ||||
|     first_play_name = "N/A" | ||||
|     first_play_date = "N/A" | ||||
|     last_play_name = "N/A" | ||||
|     last_play_date = "N/A" | ||||
|     if this_year_sessions: | ||||
|         first_session = this_year_sessions.earliest() | ||||
|         first_play_game = first_session.purchase.edition.game | ||||
|         first_play_date = first_session.timestamp_start.strftime("%x") | ||||
|         last_session = this_year_sessions.latest() | ||||
|         last_play_game = last_session.purchase.edition.game | ||||
|         last_play_date = last_session.timestamp_start.strftime("%x") | ||||
|  | ||||
|     all_purchased_this_year_count = this_year_purchases_with_currency.count() | ||||
|     all_purchased_refunded_this_year_count = this_year_purchases_refunded.count() | ||||
|  | ||||
|     this_year_purchases_dropped_count = this_year_purchases_dropped.count() | ||||
|     this_year_purchases_dropped_percentage = int( | ||||
|         safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count) | ||||
|         * 100 | ||||
|     ) | ||||
|     context = { | ||||
|         "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( | ||||
|             edition__year_released=year | ||||
|         ).count(), | ||||
|         "top_10_games_by_playtime": top_10_games_by_playtime, | ||||
|         "year": year, | ||||
|         "total_playtime_per_platform": total_playtime_per_platform, | ||||
|         "total_spent": total_spent, | ||||
|         "total_spent_currency": selected_currency, | ||||
|         "all_purchased_this_year": this_year_purchases_without_refunded, | ||||
|         "spent_per_game": int( | ||||
|             safe_division(total_spent, this_year_purchases_without_refunded_count) | ||||
|         ), | ||||
|         "all_finished_this_year": purchases_finished_this_year.select_related( | ||||
|             "edition" | ||||
|         ).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.select_related( | ||||
|             "edition" | ||||
|         ).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.select_related( | ||||
|             "edition" | ||||
|         ).order_by( | ||||
|             "date_finished" | ||||
|         ), | ||||
|         "total_sessions": this_year_sessions.count(), | ||||
|         "unique_days": unique_days["dates"], | ||||
|         "unique_days_percent": int(unique_days["dates"] / 365 * 100), | ||||
|         "purchased_unfinished": this_year_purchases_unfinished, | ||||
|         "purchased_unfinished_count": this_year_purchases_unfinished_count, | ||||
|         "unfinished_purchases_percent": this_year_purchases_unfinished_percent, | ||||
|         "dropped_count": this_year_purchases_dropped_count, | ||||
|         "dropped_percentage": this_year_purchases_dropped_percentage, | ||||
|         "refunded_percent": int( | ||||
|             safe_division( | ||||
|                 all_purchased_refunded_this_year_count, | ||||
|                 all_purchased_this_year_count, | ||||
|             ) | ||||
|             * 100 | ||||
|         ), | ||||
|         "all_purchased_refunded_this_year": this_year_purchases_refunded, | ||||
|         "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, | ||||
|         "all_purchased_this_year": this_year_purchases_with_currency.order_by( | ||||
|             "date_purchased" | ||||
|         ), | ||||
|         "all_purchased_this_year_count": all_purchased_this_year_count, | ||||
|         "backlog_decrease_count": backlog_decrease_count, | ||||
|         "longest_session_time": ( | ||||
|             format_duration(longest_session.duration, "%2.0Hh %2.0mm") | ||||
|             if longest_session | ||||
|             else 0 | ||||
|         ), | ||||
|         "longest_session_game": ( | ||||
|             longest_session.purchase.edition.game if longest_session else None | ||||
|         ), | ||||
|         "highest_session_count": ( | ||||
|             game_highest_session_count.session_count | ||||
|             if game_highest_session_count | ||||
|             else 0 | ||||
|         ), | ||||
|         "highest_session_count_game": ( | ||||
|             game_highest_session_count if game_highest_session_count else None | ||||
|         ), | ||||
|         "highest_session_average": ( | ||||
|             format_duration( | ||||
|                 highest_session_average_game.session_average, "%2.0Hh %2.0mm" | ||||
|             ) | ||||
|             if highest_session_average_game | ||||
|             else 0 | ||||
|         ), | ||||
|         "highest_session_average_game": highest_session_average_game, | ||||
|         "first_play_game": first_play_game, | ||||
|         "first_play_date": first_play_date, | ||||
|         "last_play_game": last_play_game, | ||||
|         "last_play_date": last_play_date, | ||||
|         "title": f"{year} Stats", | ||||
|         "month_playtimes": month_playtimes, | ||||
|     } | ||||
|  | ||||
|     request.session["return_path"] = request.path | ||||
|     return render(request, "stats.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_purchase(request, purchase_id=None): | ||||
|     purchase = get_object_or_404(Purchase, id=purchase_id) | ||||
|     purchase.delete() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_purchase(request, edition_id=None): | ||||
|     context = {} | ||||
|     initial = {"date_purchased": timezone.now()} | ||||
|  | ||||
|     if request.method == "POST": | ||||
|         form = PurchaseForm(request.POST or None, initial=initial) | ||||
|         if form.is_valid(): | ||||
|             purchase = form.save() | ||||
|             if "submit_and_redirect" in request.POST: | ||||
|                 return HttpResponseRedirect( | ||||
|                     reverse( | ||||
|                         "add_session_for_purchase", kwargs={"purchase_id": purchase.id} | ||||
|                     ) | ||||
|                 ) | ||||
|             else: | ||||
|                 return redirect("index") | ||||
|     else: | ||||
|         if edition_id: | ||||
|             edition = Edition.objects.get(id=edition_id) | ||||
|             form = PurchaseForm( | ||||
|                 initial={ | ||||
|                     **initial, | ||||
|                     "edition": edition, | ||||
|                     "platform": edition.platform, | ||||
|                 } | ||||
|             ) | ||||
|         else: | ||||
|             form = PurchaseForm(initial=initial) | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Purchase" | ||||
|     context["script_name"] = "add_purchase.js" | ||||
|     return render(request, "add_purchase.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_game(request): | ||||
|     context = {} | ||||
|     form = GameForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         game = form.save() | ||||
|         if "submit_and_redirect" in request.POST: | ||||
|             return HttpResponseRedirect( | ||||
|                 reverse("add_edition_for_game", kwargs={"game_id": game.id}) | ||||
|             ) | ||||
|         else: | ||||
|             return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Game" | ||||
|     context["script_name"] = "add_game.js" | ||||
|     return render(request, "add_game.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_edition(request, game_id=None): | ||||
|     context = {} | ||||
|     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 = Game.objects.get(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) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_platform(request): | ||||
|     context = {} | ||||
|     form = PlatformForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Platform" | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_device(request): | ||||
|     context = {} | ||||
|     form = DeviceForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Device" | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def index(request): | ||||
|     return redirect("list_sessions_recent") | ||||
							
								
								
									
										0
									
								
								games/views/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								games/views/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										105
									
								
								games/views/device.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								games/views/device.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,105 @@ | ||||
| from typing import Any | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.core.paginator import Paginator | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| 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.utils import A, Button | ||||
| from games.forms import DeviceForm | ||||
| from games.models import Device | ||||
| from games.views.general import dateformat | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def list_devices(request: HttpRequest) -> HttpResponse: | ||||
|     context: dict[Any, Any] = {} | ||||
|     page_number = request.GET.get("page", 1) | ||||
|     limit = request.GET.get("limit", 10) | ||||
|     devices = Device.objects.order_by("-created_at") | ||||
|     page_obj = None | ||||
|     if int(limit) != 0: | ||||
|         paginator = Paginator(devices, limit) | ||||
|         page_obj = paginator.get_page(page_number) | ||||
|         devices = page_obj.object_list | ||||
|  | ||||
|     context = { | ||||
|         "title": "Manage devices", | ||||
|         "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 device"), url="add_device"), | ||||
|             "columns": [ | ||||
|                 "Name", | ||||
|                 "Type", | ||||
|                 "Created", | ||||
|                 "Actions", | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     device.name, | ||||
|                     device.get_type_display(), | ||||
|                     device.created_at.strftime(dateformat), | ||||
|                     render_to_string( | ||||
|                         "cotton/button_group_sm.html", | ||||
|                         { | ||||
|                             "buttons": [ | ||||
|                                 { | ||||
|                                     "href": reverse("edit_device", args=[device.pk]), | ||||
|                                     "text": "Edit", | ||||
|                                     "color": "gray", | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     "href": reverse("delete_device", args=[device.pk]), | ||||
|                                     "text": "Delete", | ||||
|                                     "color": "red", | ||||
|                                 }, | ||||
|                             ] | ||||
|                         }, | ||||
|                     ), | ||||
|                 ] | ||||
|                 for device in devices | ||||
|             ], | ||||
|         }, | ||||
|     } | ||||
|     return render(request, "list_purchases.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse: | ||||
|     device = get_object_or_404(Device, id=device_id) | ||||
|     form = DeviceForm(request.POST or None, instance=device) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_devices") | ||||
|  | ||||
|     context: dict[str, Any] = {"form": form, "title": "Edit device"} | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_device(request: HttpRequest, device_id: int) -> HttpResponse: | ||||
|     device = get_object_or_404(Device, id=device_id) | ||||
|     device.delete() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_device(request: HttpRequest) -> HttpResponse: | ||||
|     context: dict[str, Any] = {} | ||||
|     form = DeviceForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Device" | ||||
|     return render(request, "add.html", context) | ||||
							
								
								
									
										157
									
								
								games/views/edition.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								games/views/edition.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,157 @@ | ||||
| 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.utils import A, Button, truncate_with_popover | ||||
| from games.forms import EditionForm | ||||
| from games.models import Edition, Game | ||||
| from games.views.general import dateformat | ||||
|  | ||||
|  | ||||
| @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", | ||||
|                 "Platform", | ||||
|                 "Year", | ||||
|                 "Wikidata", | ||||
|                 "Created", | ||||
|                 "Actions", | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     A( | ||||
|                         [ | ||||
|                             ( | ||||
|                                 "href", | ||||
|                                 reverse( | ||||
|                                     "view_game", | ||||
|                                     args=[edition.game.pk], | ||||
|                                 ), | ||||
|                             ) | ||||
|                         ], | ||||
|                         truncate_with_popover(edition.game.name), | ||||
|                     ), | ||||
|                     truncate_with_popover( | ||||
|                         edition.name | ||||
|                         if edition.game.name != edition.name | ||||
|                         else "(identical)" | ||||
|                     ), | ||||
|                     truncate_with_popover( | ||||
|                         edition.sort_name | ||||
|                         if edition.sort_name is not None | ||||
|                         and edition.game.name != edition.sort_name | ||||
|                         else "(identical)" | ||||
|                     ), | ||||
|                     truncate_with_popover(str(edition.platform)), | ||||
|                     edition.year_released, | ||||
|                     edition.wikidata, | ||||
|                     edition.created_at.strftime(dateformat), | ||||
|                     render_to_string( | ||||
|                         "cotton/button_group_sm.html", | ||||
|                         { | ||||
|                             "buttons": [ | ||||
|                                 { | ||||
|                                     "href": reverse("edit_edition", args=[edition.pk]), | ||||
|                                     "text": "Edit", | ||||
|                                     "color": "gray", | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     "href": reverse( | ||||
|                                         "delete_edition", args=[edition.pk] | ||||
|                                     ), | ||||
|                                     "text": "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) | ||||
							
								
								
									
										335
									
								
								games/views/game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										335
									
								
								games/views/game.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,335 @@ | ||||
| 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.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.time import format_duration | ||||
| from common.utils import A, Button, safe_division, truncate_with_popover | ||||
| from games.forms import GameForm | ||||
| from games.models import Edition, Game, Purchase, Session | ||||
| from games.views.general import ( | ||||
|     dateformat, | ||||
|     datetimeformat, | ||||
|     durationformat, | ||||
|     durationformat_manual, | ||||
|     timeformat, | ||||
|     use_custom_redirect, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def list_games(request: HttpRequest) -> 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 | ||||
|     if int(limit) != 0: | ||||
|         paginator = Paginator(games, limit) | ||||
|         page_obj = paginator.get_page(page_number) | ||||
|         games = page_obj.object_list | ||||
|  | ||||
|     context = { | ||||
|         "title": "Manage games", | ||||
|         "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 game"), url="add_game"), | ||||
|             "columns": [ | ||||
|                 "Name", | ||||
|                 "Sort Name", | ||||
|                 "Year", | ||||
|                 "Wikidata", | ||||
|                 "Created", | ||||
|                 "Actions", | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     A( | ||||
|                         [ | ||||
|                             ( | ||||
|                                 "href", | ||||
|                                 reverse( | ||||
|                                     "view_game", | ||||
|                                     args=[game.pk], | ||||
|                                 ), | ||||
|                             ) | ||||
|                         ], | ||||
|                         truncate_with_popover(game.name), | ||||
|                     ), | ||||
|                     truncate_with_popover( | ||||
|                         game.sort_name | ||||
|                         if game.sort_name is not None and game.name != game.sort_name | ||||
|                         else "(identical)" | ||||
|                     ), | ||||
|                     game.year_released, | ||||
|                     game.wikidata, | ||||
|                     game.created_at.strftime(dateformat), | ||||
|                     render_to_string( | ||||
|                         "cotton/button_group_sm.html", | ||||
|                         { | ||||
|                             "buttons": [ | ||||
|                                 { | ||||
|                                     "href": reverse("edit_game", args=[game.pk]), | ||||
|                                     "text": "Edit", | ||||
|                                     "color": "gray", | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     "href": reverse("delete_game", args=[game.pk]), | ||||
|                                     "text": "Delete", | ||||
|                                     "color": "red", | ||||
|                                 }, | ||||
|                             ] | ||||
|                         }, | ||||
|                     ), | ||||
|                 ] | ||||
|                 for game in games | ||||
|             ], | ||||
|         }, | ||||
|     } | ||||
|     return render(request, "list_purchases.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_game(request: HttpRequest) -> HttpResponse: | ||||
|     context: dict[str, Any] = {} | ||||
|     form = GameForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         game = form.save() | ||||
|         if "submit_and_redirect" in request.POST: | ||||
|             return HttpResponseRedirect( | ||||
|                 reverse("add_edition_for_game", kwargs={"game_id": game.id}) | ||||
|             ) | ||||
|         else: | ||||
|             return redirect("list_games") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Game" | ||||
|     context["script_name"] = "add_game.js" | ||||
|     return render(request, "add_game.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|     game = get_object_or_404(Game, id=game_id) | ||||
|     game.delete() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|     context = {} | ||||
|     purchase = get_object_or_404(Game, id=game_id) | ||||
|     form = GameForm(request.POST or None, instance=purchase) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Game" | ||||
|     context["form"] = form | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def view_game(request: HttpRequest, game_id: int) -> HttpResponse: | ||||
|     game = Game.objects.get(id=game_id) | ||||
|     nongame_related_purchases_prefetch: Prefetch[Purchase] = Prefetch( | ||||
|         "related_purchases", | ||||
|         queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by( | ||||
|             "date_purchased" | ||||
|         ), | ||||
|         to_attr="nongame_related_purchases", | ||||
|     ) | ||||
|     game_purchases_prefetch: Prefetch[Purchase] = Prefetch( | ||||
|         "purchase_set", | ||||
|         queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related( | ||||
|             nongame_related_purchases_prefetch | ||||
|         ), | ||||
|         to_attr="game_purchases", | ||||
|     ) | ||||
|     editions = ( | ||||
|         Edition.objects.filter(game=game) | ||||
|         .prefetch_related(game_purchases_prefetch) | ||||
|         .order_by("year_released") | ||||
|     ) | ||||
|  | ||||
|     purchases = Purchase.objects.filter(edition__game=game).order_by("date_purchased") | ||||
|  | ||||
|     sessions = Session.objects.prefetch_related("device").filter( | ||||
|         purchase__edition__game=game | ||||
|     ) | ||||
|     session_count = sessions.count() | ||||
|     session_count_without_manual = ( | ||||
|         Session.objects.without_manual().filter(purchase__edition__game=game).count() | ||||
|     ) | ||||
|  | ||||
|     if sessions: | ||||
|         playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y") | ||||
|         latest_session = sessions.latest() | ||||
|         playrange_end = latest_session.timestamp_start.strftime("%b %Y") | ||||
|  | ||||
|         playrange = ( | ||||
|             playrange_start | ||||
|             if playrange_start == playrange_end | ||||
|             else f"{playrange_start} — {playrange_end}" | ||||
|         ) | ||||
|     else: | ||||
|         playrange = "N/A" | ||||
|         latest_session = None | ||||
|  | ||||
|     total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H")) | ||||
|     total_hours_without_manual = float( | ||||
|         format_duration(sessions.calculated_duration_unformatted(), "%2.1H") | ||||
|     ) | ||||
|  | ||||
|     edition_data: dict[str, Any] = { | ||||
|         "columns": [ | ||||
|             "Name", | ||||
|             "Platform", | ||||
|             "Year Released", | ||||
|             "Actions", | ||||
|         ], | ||||
|         "rows": [ | ||||
|             [ | ||||
|                 edition.name, | ||||
|                 edition.platform, | ||||
|                 edition.year_released, | ||||
|                 render_to_string( | ||||
|                     "cotton/button_group_sm.html", | ||||
|                     { | ||||
|                         "buttons": [ | ||||
|                             { | ||||
|                                 "href": reverse("edit_edition", args=[edition.pk]), | ||||
|                                 "text": "Edit", | ||||
|                                 "color": "gray", | ||||
|                             }, | ||||
|                             { | ||||
|                                 "href": reverse("delete_edition", args=[edition.pk]), | ||||
|                                 "text": "Delete", | ||||
|                                 "color": "red", | ||||
|                             }, | ||||
|                         ] | ||||
|                     }, | ||||
|                 ), | ||||
|             ] | ||||
|             for edition in editions | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|     purchase_data: dict[str, Any] = { | ||||
|         "columns": ["Name", "Type", "Price", "Actions"], | ||||
|         "rows": [ | ||||
|             [ | ||||
|                 purchase.name if purchase.name else purchase.edition.name, | ||||
|                 purchase.get_type_display(), | ||||
|                 f"{purchase.price} {purchase.price_currency}", | ||||
|                 render_to_string( | ||||
|                     "cotton/button_group_sm.html", | ||||
|                     { | ||||
|                         "buttons": [ | ||||
|                             { | ||||
|                                 "href": reverse("edit_purchase", args=[purchase.pk]), | ||||
|                                 "text": "Edit", | ||||
|                                 "color": "gray", | ||||
|                             }, | ||||
|                             { | ||||
|                                 "href": reverse("delete_purchase", args=[purchase.pk]), | ||||
|                                 "text": "Delete", | ||||
|                                 "color": "red", | ||||
|                             }, | ||||
|                         ] | ||||
|                     }, | ||||
|                 ), | ||||
|             ] | ||||
|             for purchase in purchases | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|     sessions_all = Session.objects.filter(purchase__edition__game=game).order_by( | ||||
|         "-timestamp_start" | ||||
|     ) | ||||
|     session_count = sessions_all.count() | ||||
|     session_paginator = Paginator(sessions_all, 5) | ||||
|     page_number = request.GET.get("page", 1) | ||||
|     session_page_obj = session_paginator.get_page(page_number) | ||||
|     sessions = session_page_obj.object_list | ||||
|  | ||||
|     session_data: dict[str, Any] = { | ||||
|         "columns": ["Date", "Duration", "Duration (manual)", "Actions"], | ||||
|         "rows": [ | ||||
|             [ | ||||
|                 f"{session.timestamp_start.strftime(datetimeformat)}{f" — {session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}", | ||||
|                 ( | ||||
|                     format_duration(session.duration_calculated, durationformat) | ||||
|                     if session.duration_calculated | ||||
|                     else "-" | ||||
|                 ), | ||||
|                 ( | ||||
|                     format_duration(session.duration_manual, durationformat_manual) | ||||
|                     if session.duration_manual | ||||
|                     else "-" | ||||
|                 ), | ||||
|                 render_to_string( | ||||
|                     "cotton/button_group_sm.html", | ||||
|                     { | ||||
|                         "buttons": [ | ||||
|                             { | ||||
|                                 "href": reverse("edit_session", args=[session.pk]), | ||||
|                                 "text": "Edit", | ||||
|                                 "color": "gray", | ||||
|                             }, | ||||
|                             { | ||||
|                                 "href": reverse("delete_session", args=[session.pk]), | ||||
|                                 "text": "Delete", | ||||
|                                 "color": "red", | ||||
|                             }, | ||||
|                         ] | ||||
|                     }, | ||||
|                 ), | ||||
|             ] | ||||
|             for session in sessions | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|     context: dict[str, Any] = { | ||||
|         "edition_count": editions.count(), | ||||
|         "editions": editions, | ||||
|         "game": game, | ||||
|         "playrange": playrange, | ||||
|         "purchase_count": Purchase.objects.filter(edition__game=game).count(), | ||||
|         "session_average_without_manual": round( | ||||
|             safe_division( | ||||
|                 total_hours_without_manual, int(session_count_without_manual) | ||||
|             ), | ||||
|             1, | ||||
|         ), | ||||
|         "session_count": session_count, | ||||
|         "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, | ||||
|         "session_elided_page_range": ( | ||||
|             session_page_obj.paginator.get_elided_page_range( | ||||
|                 page_number, on_each_side=1, on_ends=1 | ||||
|             ) | ||||
|             if session_page_obj and session_count > 5 | ||||
|             else None | ||||
|         ), | ||||
|     } | ||||
|  | ||||
|     request.session["return_path"] = request.path | ||||
|     return render(request, "view_game.html", context) | ||||
							
								
								
									
										510
									
								
								games/views/general.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										510
									
								
								games/views/general.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,510 @@ | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.db.models import Avg, Count, ExpressionWrapper, F, Q, Sum, fields | ||||
| from django.db.models.functions import TruncDate, TruncMonth | ||||
| 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 common.time import format_duration | ||||
| from common.utils import safe_division | ||||
| from games.models import Edition, Game, Platform, Purchase, Session | ||||
|  | ||||
| dateformat: str = "%d/%m/%Y" | ||||
| datetimeformat: str = "%d/%m/%Y %H:%M" | ||||
| timeformat: str = "%H:%M" | ||||
| durationformat: str = "%2.1H hours" | ||||
| durationformat_manual: str = "%H hours" | ||||
|  | ||||
|  | ||||
| def model_counts(request: HttpRequest) -> dict[str, bool]: | ||||
|     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(), | ||||
|     } | ||||
|  | ||||
|  | ||||
| def use_custom_redirect( | ||||
|     func: Callable[..., HttpResponse], | ||||
| ) -> Callable[..., HttpResponse]: | ||||
|     """ | ||||
|     Will redirect to "return_path" session variable if set. | ||||
|     """ | ||||
|  | ||||
|     def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||
|         response = func(request, *args, **kwargs) | ||||
|         if isinstance(response, HttpResponseRedirect) and ( | ||||
|             next_url := request.session.get("return_path") | ||||
|         ): | ||||
|             return HttpResponseRedirect(next_url) | ||||
|         return response | ||||
|  | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def stats_alltime(request: HttpRequest) -> HttpResponse: | ||||
|     year = "Alltime" | ||||
|     this_year_sessions = Session.objects.all().select_related("purchase__edition") | ||||
|     this_year_sessions_with_durations = this_year_sessions.annotate( | ||||
|         duration=ExpressionWrapper( | ||||
|             F("timestamp_end") - F("timestamp_start"), | ||||
|             output_field=fields.DurationField(), | ||||
|         ) | ||||
|     ) | ||||
|     longest_session = this_year_sessions_with_durations.order_by("-duration").first() | ||||
|     this_year_games = Game.objects.filter( | ||||
|         edition__purchase__session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|     this_year_games_with_session_counts = this_year_games.annotate( | ||||
|         session_count=Count("edition__purchase__session"), | ||||
|     ) | ||||
|     game_highest_session_count = this_year_games_with_session_counts.order_by( | ||||
|         "-session_count" | ||||
|     ).first() | ||||
|     selected_currency = "CZK" | ||||
|     unique_days = ( | ||||
|         this_year_sessions.annotate(date=TruncDate("timestamp_start")) | ||||
|         .values("date") | ||||
|         .distinct() | ||||
|         .aggregate(dates=Count("date")) | ||||
|     ) | ||||
|     this_year_played_purchases = Purchase.objects.filter( | ||||
|         session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|  | ||||
|     this_year_purchases = Purchase.objects.all() | ||||
|     this_year_purchases_with_currency = this_year_purchases.select_related( | ||||
|         "edition" | ||||
|     ).filter(price_currency__exact=selected_currency) | ||||
|     this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( | ||||
|         date_refunded=None | ||||
|     ) | ||||
|     this_year_purchases_refunded = this_year_purchases_with_currency.refunded() | ||||
|  | ||||
|     this_year_purchases_unfinished_dropped_nondropped = ( | ||||
|         this_year_purchases_without_refunded.filter(date_finished__isnull=True) | ||||
|         .filter(infinite=False) | ||||
|         .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) | ||||
|     )  # do not count battle passes etc. | ||||
|  | ||||
|     this_year_purchases_unfinished = ( | ||||
|         this_year_purchases_unfinished_dropped_nondropped.filter( | ||||
|             date_dropped__isnull=True | ||||
|         ) | ||||
|     ) | ||||
|     this_year_purchases_dropped = ( | ||||
|         this_year_purchases_unfinished_dropped_nondropped.filter( | ||||
|             date_dropped__isnull=False | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
|     this_year_purchases_without_refunded_count = ( | ||||
|         this_year_purchases_without_refunded.count() | ||||
|     ) | ||||
|     this_year_purchases_unfinished_count = this_year_purchases_unfinished.count() | ||||
|     this_year_purchases_unfinished_percent = int( | ||||
|         safe_division( | ||||
|             this_year_purchases_unfinished_count, | ||||
|             this_year_purchases_without_refunded_count, | ||||
|         ) | ||||
|         * 100 | ||||
|     ) | ||||
|  | ||||
|     purchases_finished_this_year: BaseManager[Purchase] = Purchase.objects.finished() | ||||
|     purchases_finished_this_year_released_this_year = ( | ||||
|         purchases_finished_this_year.all().order_by("date_finished") | ||||
|     ) | ||||
|     purchased_this_year_finished_this_year = ( | ||||
|         this_year_purchases_without_refunded.all() | ||||
|     ).order_by("date_finished") | ||||
|  | ||||
|     this_year_spendings = this_year_purchases_without_refunded.aggregate( | ||||
|         total_spent=Sum(F("price")) | ||||
|     ) | ||||
|     total_spent = this_year_spendings["total_spent"] or 0 | ||||
|  | ||||
|     games_with_playtime = ( | ||||
|         Game.objects.filter(edition__purchase__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             total_playtime=Sum( | ||||
|                 F("edition__purchase__session__duration_calculated") | ||||
|                 + F("edition__purchase__session__duration_manual") | ||||
|             ) | ||||
|         ) | ||||
|         .values("id", "name", "total_playtime") | ||||
|     ) | ||||
|     month_playtimes = ( | ||||
|         this_year_sessions.annotate(month=TruncMonth("timestamp_start")) | ||||
|         .values("month") | ||||
|         .annotate(playtime=Sum("duration_calculated")) | ||||
|         .order_by("month") | ||||
|     ) | ||||
|     for month in month_playtimes: | ||||
|         month["playtime"] = format_duration(month["playtime"], "%2.0H") | ||||
|  | ||||
|     highest_session_average_game = ( | ||||
|         Game.objects.filter(edition__purchase__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             session_average=Avg("edition__purchase__session__duration_calculated") | ||||
|         ) | ||||
|         .order_by("-session_average") | ||||
|         .first() | ||||
|     ) | ||||
|     top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] | ||||
|     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") | ||||
|         .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) | ||||
|         .annotate(platform_name=F("purchase__platform__name")) | ||||
|         .values("platform_name", "total_playtime") | ||||
|         .order_by("-total_playtime") | ||||
|     ) | ||||
|     for item in total_playtime_per_platform: | ||||
|         item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H") | ||||
|  | ||||
|     backlog_decrease_count = ( | ||||
|         Purchase.objects.all().intersection(purchases_finished_this_year).count() | ||||
|     ) | ||||
|  | ||||
|     first_play_date = "N/A" | ||||
|     last_play_date = "N/A" | ||||
|     if this_year_sessions: | ||||
|         first_session = this_year_sessions.earliest() | ||||
|         first_play_game = first_session.purchase.edition.game | ||||
|         first_play_date = first_session.timestamp_start.strftime("%x") | ||||
|         last_session = this_year_sessions.latest() | ||||
|         last_play_game = last_session.purchase.edition.game | ||||
|         last_play_date = last_session.timestamp_start.strftime("%x") | ||||
|  | ||||
|     all_purchased_this_year_count = this_year_purchases_with_currency.count() | ||||
|     all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count() | ||||
|  | ||||
|     this_year_purchases_dropped_count = this_year_purchases_dropped.count() | ||||
|     this_year_purchases_dropped_percentage = int( | ||||
|         safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count) | ||||
|         * 100 | ||||
|     ) | ||||
|     context = { | ||||
|         "total_hours": format_duration( | ||||
|             this_year_sessions.total_duration_unformatted(), "%2.0H" | ||||
|         ), | ||||
|         "total_2023_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, | ||||
|         "total_spent": total_spent, | ||||
|         "total_spent_currency": selected_currency, | ||||
|         "spent_per_game": int( | ||||
|             safe_division(total_spent, this_year_purchases_without_refunded_count) | ||||
|         ), | ||||
|         "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), | ||||
|         "total_sessions": this_year_sessions.count(), | ||||
|         "unique_days": unique_days["dates"], | ||||
|         "unique_days_percent": int(unique_days["dates"] / 365 * 100), | ||||
|         "purchased_unfinished_count": this_year_purchases_unfinished_count, | ||||
|         "unfinished_purchases_percent": this_year_purchases_unfinished_percent, | ||||
|         "dropped_count": this_year_purchases_dropped_count, | ||||
|         "dropped_percentage": this_year_purchases_dropped_percentage, | ||||
|         "refunded_percent": int( | ||||
|             safe_division( | ||||
|                 all_purchased_refunded_this_year_count, | ||||
|                 all_purchased_this_year_count, | ||||
|             ) | ||||
|             * 100 | ||||
|         ), | ||||
|         "all_purchased_refunded_this_year": this_year_purchases_refunded, | ||||
|         "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, | ||||
|         "all_purchased_this_year_count": all_purchased_this_year_count, | ||||
|         "backlog_decrease_count": backlog_decrease_count, | ||||
|         "longest_session_time": ( | ||||
|             format_duration(longest_session.duration, "%2.0Hh %2.0mm") | ||||
|             if longest_session | ||||
|             else 0 | ||||
|         ), | ||||
|         "longest_session_game": ( | ||||
|             longest_session.purchase.edition.game if longest_session else None | ||||
|         ), | ||||
|         "highest_session_count": ( | ||||
|             game_highest_session_count.session_count | ||||
|             if game_highest_session_count | ||||
|             else 0 | ||||
|         ), | ||||
|         "highest_session_count_game": ( | ||||
|             game_highest_session_count if game_highest_session_count else None | ||||
|         ), | ||||
|         "highest_session_average": ( | ||||
|             format_duration( | ||||
|                 highest_session_average_game.session_average, "%2.0Hh %2.0mm" | ||||
|             ) | ||||
|             if highest_session_average_game | ||||
|             else 0 | ||||
|         ), | ||||
|         "highest_session_average_game": highest_session_average_game, | ||||
|         "first_play_game": first_play_game, | ||||
|         "first_play_date": first_play_date, | ||||
|         "last_play_game": last_play_game, | ||||
|         "last_play_date": last_play_date, | ||||
|         "title": f"{year} Stats", | ||||
|     } | ||||
|  | ||||
|     request.session["return_path"] = request.path | ||||
|     return render(request, "stats.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def stats(request: HttpRequest, year: int = 0) -> HttpResponse: | ||||
|     selected_year = request.GET.get("year") | ||||
|     if selected_year: | ||||
|         return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) | ||||
|     if year == 0: | ||||
|         return HttpResponseRedirect(reverse("stats_alltime")) | ||||
|     this_year_sessions = Session.objects.filter( | ||||
|         timestamp_start__year=year | ||||
|     ).select_related("purchase__edition") | ||||
|     this_year_sessions_with_durations = this_year_sessions.annotate( | ||||
|         duration=ExpressionWrapper( | ||||
|             F("timestamp_end") - F("timestamp_start"), | ||||
|             output_field=fields.DurationField(), | ||||
|         ) | ||||
|     ) | ||||
|     longest_session = this_year_sessions_with_durations.order_by("-duration").first() | ||||
|     this_year_games = Game.objects.filter( | ||||
|         edition__purchase__session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|     this_year_games_with_session_counts = this_year_games.annotate( | ||||
|         session_count=Count( | ||||
|             "edition__purchase__session", | ||||
|             filter=Q(edition__purchase__session__timestamp_start__year=year), | ||||
|         ) | ||||
|     ) | ||||
|     game_highest_session_count = this_year_games_with_session_counts.order_by( | ||||
|         "-session_count" | ||||
|     ).first() | ||||
|     selected_currency = "CZK" | ||||
|     unique_days = ( | ||||
|         this_year_sessions.annotate(date=TruncDate("timestamp_start")) | ||||
|         .values("date") | ||||
|         .distinct() | ||||
|         .aggregate(dates=Count("date")) | ||||
|     ) | ||||
|     this_year_played_purchases = Purchase.objects.filter( | ||||
|         session__in=this_year_sessions | ||||
|     ).distinct() | ||||
|  | ||||
|     this_year_purchases = Purchase.objects.filter(date_purchased__year=year) | ||||
|     this_year_purchases_with_currency = this_year_purchases.select_related( | ||||
|         "edition" | ||||
|     ).filter(price_currency__exact=selected_currency) | ||||
|     this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( | ||||
|         date_refunded=None | ||||
|     ) | ||||
|     this_year_purchases_refunded = this_year_purchases_with_currency.refunded() | ||||
|  | ||||
|     this_year_purchases_unfinished_dropped_nondropped = ( | ||||
|         this_year_purchases_without_refunded.filter(date_finished__isnull=True) | ||||
|         .filter(infinite=False) | ||||
|         .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) | ||||
|     )  # do not count battle passes etc. | ||||
|  | ||||
|     this_year_purchases_unfinished = ( | ||||
|         this_year_purchases_unfinished_dropped_nondropped.filter( | ||||
|             date_dropped__isnull=True | ||||
|         ) | ||||
|     ) | ||||
|     this_year_purchases_dropped = ( | ||||
|         this_year_purchases_unfinished_dropped_nondropped.filter( | ||||
|             date_dropped__isnull=False | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
|     this_year_purchases_without_refunded_count = ( | ||||
|         this_year_purchases_without_refunded.count() | ||||
|     ) | ||||
|     this_year_purchases_unfinished_count = this_year_purchases_unfinished.count() | ||||
|     this_year_purchases_unfinished_percent = int( | ||||
|         safe_division( | ||||
|             this_year_purchases_unfinished_count, | ||||
|             this_year_purchases_without_refunded_count, | ||||
|         ) | ||||
|         * 100 | ||||
|     ) | ||||
|  | ||||
|     purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) | ||||
|     purchases_finished_this_year_released_this_year = ( | ||||
|         purchases_finished_this_year.filter(edition__year_released=year).order_by( | ||||
|             "date_finished" | ||||
|         ) | ||||
|     ) | ||||
|     purchased_this_year_finished_this_year = ( | ||||
|         this_year_purchases_without_refunded.filter(date_finished__year=year) | ||||
|     ).order_by("date_finished") | ||||
|  | ||||
|     this_year_spendings = this_year_purchases_without_refunded.aggregate( | ||||
|         total_spent=Sum(F("price")) | ||||
|     ) | ||||
|     total_spent = this_year_spendings["total_spent"] or 0 | ||||
|  | ||||
|     games_with_playtime = ( | ||||
|         Game.objects.filter(edition__purchase__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             total_playtime=Sum( | ||||
|                 F("edition__purchase__session__duration_calculated") | ||||
|                 + F("edition__purchase__session__duration_manual") | ||||
|             ) | ||||
|         ) | ||||
|         .values("id", "name", "total_playtime") | ||||
|     ) | ||||
|     month_playtimes = ( | ||||
|         this_year_sessions.annotate(month=TruncMonth("timestamp_start")) | ||||
|         .values("month") | ||||
|         .annotate(playtime=Sum("duration_calculated")) | ||||
|         .order_by("month") | ||||
|     ) | ||||
|     for month in month_playtimes: | ||||
|         month["playtime"] = format_duration(month["playtime"], "%2.0H") | ||||
|  | ||||
|     highest_session_average_game = ( | ||||
|         Game.objects.filter(edition__purchase__session__in=this_year_sessions) | ||||
|         .annotate( | ||||
|             session_average=Avg("edition__purchase__session__duration_calculated") | ||||
|         ) | ||||
|         .order_by("-session_average") | ||||
|         .first() | ||||
|     ) | ||||
|     top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] | ||||
|     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") | ||||
|         .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) | ||||
|         .annotate(platform_name=F("purchase__platform__name")) | ||||
|         .values("platform_name", "total_playtime") | ||||
|         .order_by("-total_playtime") | ||||
|     ) | ||||
|     for item in total_playtime_per_platform: | ||||
|         item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H") | ||||
|  | ||||
|     backlog_decrease_count = ( | ||||
|         Purchase.objects.filter(date_purchased__year__lt=year) | ||||
|         .intersection(purchases_finished_this_year) | ||||
|         .count() | ||||
|     ) | ||||
|  | ||||
|     first_play_date = "N/A" | ||||
|     last_play_date = "N/A" | ||||
|     first_play_game = None | ||||
|     last_play_game = None | ||||
|     if this_year_sessions: | ||||
|         first_session = this_year_sessions.earliest() | ||||
|         first_play_game = first_session.purchase.edition.game | ||||
|         first_play_date = first_session.timestamp_start.strftime("%x") | ||||
|         last_session = this_year_sessions.latest() | ||||
|         last_play_game = last_session.purchase.edition.game | ||||
|         last_play_date = last_session.timestamp_start.strftime("%x") | ||||
|  | ||||
|     all_purchased_this_year_count = this_year_purchases_with_currency.count() | ||||
|     all_purchased_refunded_this_year_count = this_year_purchases_refunded.count() | ||||
|  | ||||
|     this_year_purchases_dropped_count = this_year_purchases_dropped.count() | ||||
|     this_year_purchases_dropped_percentage = int( | ||||
|         safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count) | ||||
|         * 100 | ||||
|     ) | ||||
|     context = { | ||||
|         "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( | ||||
|             edition__year_released=year | ||||
|         ).count(), | ||||
|         "top_10_games_by_playtime": top_10_games_by_playtime, | ||||
|         "year": year, | ||||
|         "total_playtime_per_platform": total_playtime_per_platform, | ||||
|         "total_spent": total_spent, | ||||
|         "total_spent_currency": selected_currency, | ||||
|         "all_purchased_this_year": this_year_purchases_without_refunded, | ||||
|         "spent_per_game": int( | ||||
|             safe_division(total_spent, this_year_purchases_without_refunded_count) | ||||
|         ), | ||||
|         "all_finished_this_year": purchases_finished_this_year.select_related( | ||||
|             "edition" | ||||
|         ).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.select_related( | ||||
|             "edition" | ||||
|         ).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.select_related( | ||||
|             "edition" | ||||
|         ).order_by("date_finished"), | ||||
|         "total_sessions": this_year_sessions.count(), | ||||
|         "unique_days": unique_days["dates"], | ||||
|         "unique_days_percent": int(unique_days["dates"] / 365 * 100), | ||||
|         "purchased_unfinished": this_year_purchases_unfinished, | ||||
|         "purchased_unfinished_count": this_year_purchases_unfinished_count, | ||||
|         "unfinished_purchases_percent": this_year_purchases_unfinished_percent, | ||||
|         "dropped_count": this_year_purchases_dropped_count, | ||||
|         "dropped_percentage": this_year_purchases_dropped_percentage, | ||||
|         "refunded_percent": int( | ||||
|             safe_division( | ||||
|                 all_purchased_refunded_this_year_count, | ||||
|                 all_purchased_this_year_count, | ||||
|             ) | ||||
|             * 100 | ||||
|         ), | ||||
|         "all_purchased_refunded_this_year": this_year_purchases_refunded, | ||||
|         "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, | ||||
|         "all_purchased_this_year": this_year_purchases_with_currency.order_by( | ||||
|             "date_purchased" | ||||
|         ), | ||||
|         "all_purchased_this_year_count": all_purchased_this_year_count, | ||||
|         "backlog_decrease_count": backlog_decrease_count, | ||||
|         "longest_session_time": ( | ||||
|             format_duration(longest_session.duration, "%2.0Hh %2.0mm") | ||||
|             if longest_session | ||||
|             else 0 | ||||
|         ), | ||||
|         "longest_session_game": ( | ||||
|             longest_session.purchase.edition.game if longest_session else None | ||||
|         ), | ||||
|         "highest_session_count": ( | ||||
|             game_highest_session_count.session_count | ||||
|             if game_highest_session_count | ||||
|             else 0 | ||||
|         ), | ||||
|         "highest_session_count_game": ( | ||||
|             game_highest_session_count if game_highest_session_count else None | ||||
|         ), | ||||
|         "highest_session_average": ( | ||||
|             format_duration( | ||||
|                 highest_session_average_game.session_average, "%2.0Hh %2.0mm" | ||||
|             ) | ||||
|             if highest_session_average_game | ||||
|             else 0 | ||||
|         ), | ||||
|         "highest_session_average_game": highest_session_average_game, | ||||
|         "first_play_game": first_play_game, | ||||
|         "first_play_date": first_play_date, | ||||
|         "last_play_game": last_play_game, | ||||
|         "last_play_date": last_play_date, | ||||
|         "title": f"{year} Stats", | ||||
|         "month_playtimes": month_playtimes, | ||||
|     } | ||||
|  | ||||
|     request.session["return_path"] = request.path | ||||
|     return render(request, "stats.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def index(request: HttpRequest) -> HttpResponse: | ||||
|     return redirect("list_sessions") | ||||
							
								
								
									
										111
									
								
								games/views/platform.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								games/views/platform.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,111 @@ | ||||
| from typing import Any | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.core.paginator import Paginator | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| 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.utils import A, Button | ||||
| from games.forms import PlatformForm | ||||
| from games.models import Platform | ||||
| from games.views.general import dateformat, use_custom_redirect | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def list_platforms(request: HttpRequest) -> HttpResponse: | ||||
|     context: dict[Any, Any] = {} | ||||
|     page_number = request.GET.get("page", 1) | ||||
|     limit = request.GET.get("limit", 10) | ||||
|     platforms = Platform.objects.order_by("-created_at") | ||||
|     page_obj = None | ||||
|     if int(limit) != 0: | ||||
|         paginator = Paginator(platforms, limit) | ||||
|         page_obj = paginator.get_page(page_number) | ||||
|         platforms = page_obj.object_list | ||||
|  | ||||
|     context = { | ||||
|         "title": "Manage platforms", | ||||
|         "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 platform"), url="add_platform"), | ||||
|             "columns": [ | ||||
|                 "Name", | ||||
|                 "Group", | ||||
|                 "Created", | ||||
|                 "Actions", | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     platform.name, | ||||
|                     platform.group, | ||||
|                     platform.created_at.strftime(dateformat), | ||||
|                     render_to_string( | ||||
|                         "cotton/button_group_sm.html", | ||||
|                         { | ||||
|                             "buttons": [ | ||||
|                                 { | ||||
|                                     "href": reverse( | ||||
|                                         "edit_platform", args=[platform.pk] | ||||
|                                     ), | ||||
|                                     "text": "Edit", | ||||
|                                     "color": "gray", | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     "href": reverse( | ||||
|                                         "delete_platform", args=[platform.pk] | ||||
|                                     ), | ||||
|                                     "text": "Delete", | ||||
|                                     "color": "red", | ||||
|                                 }, | ||||
|                             ] | ||||
|                         }, | ||||
|                     ), | ||||
|                 ] | ||||
|                 for platform in platforms | ||||
|             ], | ||||
|         }, | ||||
|     } | ||||
|     return render(request, "list_purchases.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse: | ||||
|     platform = get_object_or_404(Platform, id=platform_id) | ||||
|     platform.delete() | ||||
|     return redirect("list_platforms") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse: | ||||
|     context = {} | ||||
|     platform = get_object_or_404(Platform, id=platform_id) | ||||
|     form = PlatformForm(request.POST or None, instance=platform) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_platforms") | ||||
|     context["title"] = "Edit Platform" | ||||
|     context["form"] = form | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_platform(request: HttpRequest) -> HttpResponse: | ||||
|     context: dict[str, Any] = {} | ||||
|     form = PlatformForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Platform" | ||||
|     return render(request, "add.html", context) | ||||
							
								
								
									
										196
									
								
								games/views/purchase.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								games/views/purchase.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,196 @@ | ||||
| from typing import Any | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.core.paginator import Paginator | ||||
| from django.http import ( | ||||
|     HttpRequest, | ||||
|     HttpResponse, | ||||
|     HttpResponseBadRequest, | ||||
|     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 django.utils import timezone | ||||
|  | ||||
| from common.utils import A, Button, truncate_with_popover | ||||
| from games.forms import PurchaseForm | ||||
| from games.models import Edition, Purchase | ||||
| from games.views.general import dateformat, use_custom_redirect | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def list_purchases(request: HttpRequest) -> HttpResponse: | ||||
|     context: dict[Any, Any] = {} | ||||
|     page_number = request.GET.get("page", 1) | ||||
|     limit = request.GET.get("limit", 10) | ||||
|     purchases = Purchase.objects.order_by("-date_purchased") | ||||
|     page_obj = None | ||||
|     if int(limit) != 0: | ||||
|         paginator = Paginator(purchases, limit) | ||||
|         page_obj = paginator.get_page(page_number) | ||||
|         purchases = page_obj.object_list | ||||
|  | ||||
|     context = { | ||||
|         "title": "Manage purchases", | ||||
|         "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 purchase"), url="add_purchase"), | ||||
|             "columns": [ | ||||
|                 "Name", | ||||
|                 "Type", | ||||
|                 "Platform", | ||||
|                 "Price", | ||||
|                 "Currency", | ||||
|                 "Infinite", | ||||
|                 "Purchased", | ||||
|                 "Refunded", | ||||
|                 "Finished", | ||||
|                 "Dropped", | ||||
|                 "Created", | ||||
|                 "Actions", | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     A( | ||||
|                         [ | ||||
|                             ( | ||||
|                                 "href", | ||||
|                                 reverse( | ||||
|                                     "view_game", | ||||
|                                     args=[purchase.edition.game.pk], | ||||
|                                 ), | ||||
|                             ), | ||||
|                         ], | ||||
|                         truncate_with_popover( | ||||
|                             purchase.edition.game.name | ||||
|                             if purchase.type == "game" | ||||
|                             else f"{purchase.edition.game.name} ({purchase.name})" | ||||
|                         ), | ||||
|                     ), | ||||
|                     purchase.get_type_display(), | ||||
|                     purchase.platform, | ||||
|                     purchase.price, | ||||
|                     purchase.price_currency, | ||||
|                     purchase.infinite, | ||||
|                     purchase.date_purchased.strftime(dateformat), | ||||
|                     ( | ||||
|                         purchase.date_refunded.strftime(dateformat) | ||||
|                         if purchase.date_refunded | ||||
|                         else "-" | ||||
|                     ), | ||||
|                     ( | ||||
|                         purchase.date_finished.strftime(dateformat) | ||||
|                         if purchase.date_finished | ||||
|                         else "-" | ||||
|                     ), | ||||
|                     ( | ||||
|                         purchase.date_dropped.strftime(dateformat) | ||||
|                         if purchase.date_dropped | ||||
|                         else "-" | ||||
|                     ), | ||||
|                     purchase.created_at.strftime(dateformat), | ||||
|                     render_to_string( | ||||
|                         "cotton/button_group_sm.html", | ||||
|                         { | ||||
|                             "buttons": [ | ||||
|                                 { | ||||
|                                     "href": reverse( | ||||
|                                         "edit_purchase", args=[purchase.pk] | ||||
|                                     ), | ||||
|                                     "text": "Edit", | ||||
|                                     "color": "gray", | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     "href": reverse( | ||||
|                                         "delete_purchase", args=[purchase.pk] | ||||
|                                     ), | ||||
|                                     "text": "Delete", | ||||
|                                     "color": "red", | ||||
|                                 }, | ||||
|                             ] | ||||
|                         }, | ||||
|                     ), | ||||
|                 ] | ||||
|                 for purchase in purchases | ||||
|             ], | ||||
|         }, | ||||
|     } | ||||
|     return render(request, "list_purchases.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse: | ||||
|     context: dict[str, Any] = {} | ||||
|     initial = {"date_purchased": timezone.now()} | ||||
|  | ||||
|     if request.method == "POST": | ||||
|         form = PurchaseForm(request.POST or None, initial=initial) | ||||
|         if form.is_valid(): | ||||
|             purchase = form.save() | ||||
|             if "submit_and_redirect" in request.POST: | ||||
|                 return HttpResponseRedirect( | ||||
|                     reverse( | ||||
|                         "add_session_for_purchase", kwargs={"purchase_id": purchase.id} | ||||
|                     ) | ||||
|                 ) | ||||
|             else: | ||||
|                 return redirect("list_purchases") | ||||
|     else: | ||||
|         if edition_id: | ||||
|             edition = Edition.objects.get(id=edition_id) | ||||
|             form = PurchaseForm( | ||||
|                 initial={ | ||||
|                     **initial, | ||||
|                     "edition": edition, | ||||
|                     "platform": edition.platform, | ||||
|                 } | ||||
|             ) | ||||
|         else: | ||||
|             form = PurchaseForm(initial=initial) | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Purchase" | ||||
|     context["script_name"] = "add_purchase.js" | ||||
|     return render(request, "add_purchase.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: | ||||
|     context = {} | ||||
|     purchase = get_object_or_404(Purchase, id=purchase_id) | ||||
|     form = PurchaseForm(request.POST or None, instance=purchase) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Purchase" | ||||
|     context["form"] = form | ||||
|     context["purchase_id"] = str(purchase_id) | ||||
|     context["script_name"] = "add_purchase.js" | ||||
|     return render(request, "add_purchase.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: | ||||
|     purchase = get_object_or_404(Purchase, id=purchase_id) | ||||
|     purchase.delete() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| def related_purchase_by_edition(request: HttpRequest) -> HttpResponse: | ||||
|     edition_id = request.GET.get("edition") | ||||
|     if not edition_id: | ||||
|         return HttpResponseBadRequest("Invalid edition_id") | ||||
|     form = PurchaseForm() | ||||
|     form.fields["related_purchase"].queryset = Purchase.objects.filter( | ||||
|         edition_id=edition_id, type=Purchase.GAME | ||||
|     ).order_by("edition__sort_name") | ||||
|     return render(request, "partials/related_purchase_field.html", {"form": form}) | ||||
							
								
								
									
										477
									
								
								games/views/session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										477
									
								
								games/views/session.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,477 @@ | ||||
| import operator | ||||
| from functools import reduce | ||||
| from json import dumps as json_dumps | ||||
| from json import loads as json_loads | ||||
| from typing import Any, NotRequired, TypeAlias, TypedDict, TypeGuard | ||||
|  | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.core.paginator import Paginator | ||||
| from django.db.models.query import QuerySet | ||||
| from django.db.models.query_utils import Q | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.shortcuts import get_object_or_404, redirect, render | ||||
| from django.template.loader import render_to_string | ||||
| from django.urls import reverse | ||||
| from django.utils import timezone | ||||
| from typing_extensions import TypeGuard | ||||
|  | ||||
| from common.time import format_duration | ||||
| from common.utils import A, Button, truncate_with_popover | ||||
| from games.forms import SessionForm | ||||
| from games.models import Purchase, Session | ||||
| from games.views.general import ( | ||||
|     dateformat, | ||||
|     datetimeformat, | ||||
|     durationformat, | ||||
|     durationformat_manual, | ||||
|     timeformat, | ||||
|     use_custom_redirect, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class Filter(TypedDict): | ||||
|     filter_id: str | ||||
|     filter_display: str | ||||
|     filter_string: str | ||||
|  | ||||
|  | ||||
| def is_filter(obj: dict[Any, Any]) -> TypeGuard[Filter]: | ||||
|     return ( | ||||
|         isinstance(obj, dict) | ||||
|         and "filter_id" in obj | ||||
|         and isinstance(obj["filter_id"], str) | ||||
|         and "filter_display" in obj | ||||
|         and isinstance(obj["filter_display"], str) | ||||
|         and "filter_string" in obj | ||||
|         and isinstance(obj["filter_string"], str) | ||||
|     ) | ||||
|  | ||||
|  | ||||
| FilterList: TypeAlias = list[Filter] | ||||
|  | ||||
|  | ||||
| def is_filterlist(obj: list[Any]) -> TypeGuard[FilterList]: | ||||
|     return isinstance(obj, list) and all([is_filter(item) for item in obj]) | ||||
|  | ||||
|  | ||||
| ModelFilterSet: TypeAlias = list[dict[str, FilterList]] | ||||
|  | ||||
|  | ||||
| class FieldFilter(TypedDict): | ||||
|     filtered_field: str | ||||
|     filtered_value: str | ||||
|     negated: NotRequired[bool] | ||||
|     filter: Filter | ||||
|  | ||||
|  | ||||
| def is_fieldfilter(obj: dict) -> TypeGuard[FieldFilter]: | ||||
|     return ( | ||||
|         isinstance(obj, dict) | ||||
|         and "filtered_field" in obj | ||||
|         and isinstance(obj["filtered_field"], str) | ||||
|         and "filtered_value" in obj | ||||
|         and isinstance(obj["filtered_value"], str) | ||||
|         and "filter" in obj | ||||
|         and is_filter(obj["filter"]) | ||||
|     ) | ||||
|  | ||||
|  | ||||
| FilterSet: TypeAlias = list[FieldFilter] | ||||
|  | ||||
|  | ||||
| def is_filterset(obj: list) -> TypeGuard[FilterSet]: | ||||
|     return isinstance(obj, list) and all([is_fieldfilter(item) for item in obj]) | ||||
|  | ||||
|  | ||||
| iexact_filter: Filter = { | ||||
|     "filter_id": "IEXACT", | ||||
|     "filter_display": "Equals (case-insensitive)", | ||||
|     "filter_string": "__iexact", | ||||
| } | ||||
| exact_filter: Filter = { | ||||
|     "filter_id": "EXACT", | ||||
|     "filter_display": "Equals (case-sensitive)", | ||||
|     "filter_string": "__exact", | ||||
| } | ||||
| isnull_filter: Filter = { | ||||
|     "filter_id": "ISNULL", | ||||
|     "filter_display": "Is null", | ||||
|     "filter_string": "__isnull", | ||||
| } | ||||
| contains_filter: Filter = { | ||||
|     "filter_id": "CONTAINS", | ||||
|     "filter_display": "Contains", | ||||
|     "filter_string": "__contains", | ||||
| } | ||||
| startswith_filter: Filter = { | ||||
|     "filter_id": "STARTSWITH", | ||||
|     "filter_display": "Starts with", | ||||
|     "filter_string": "__startswith", | ||||
| } | ||||
| endswith_filter: Filter = { | ||||
|     "filter_id": "ENDSWITH", | ||||
|     "filter_display": "Ends with", | ||||
|     "filter_string": "__endswith", | ||||
| } | ||||
| gt_filter: Filter = { | ||||
|     "filter_id": "GT", | ||||
|     "filter_display": "Greater than", | ||||
|     "filter_string": "__gt", | ||||
| } | ||||
| lt_filter: Filter = { | ||||
|     "filter_id": "LT", | ||||
|     "filter_display": "Lesser than", | ||||
|     "filter_string": "__lt", | ||||
| } | ||||
| year_gt_filter: Filter = { | ||||
|     "filter_id": "YEARGT", | ||||
|     "filter_display": "Greater than", | ||||
|     "filter_string": "__year__gt", | ||||
| } | ||||
| year_lt_filter: Filter = { | ||||
|     "filter_id": "YEARLT", | ||||
|     "filter_display": "Lesser than", | ||||
|     "filter_string": "__year__lt", | ||||
| } | ||||
| year_exact_filter: Filter = { | ||||
|     "filter_id": "YEAREXACT", | ||||
|     "filter_display": "Equals (case-sensitive)", | ||||
|     "filter_string": "__year__exact", | ||||
| } | ||||
|  | ||||
| defined_filters = [ | ||||
|     iexact_filter, | ||||
|     exact_filter, | ||||
|     isnull_filter, | ||||
|     contains_filter, | ||||
|     startswith_filter, | ||||
|     endswith_filter, | ||||
|     gt_filter, | ||||
|     lt_filter, | ||||
|     year_gt_filter, | ||||
|     year_lt_filter, | ||||
|     year_exact_filter, | ||||
| ] | ||||
|  | ||||
| defined_filters_list = {list["filter_id"]: list for list in defined_filters} | ||||
|  | ||||
| char_filter: FilterList = [ | ||||
|     iexact_filter, | ||||
|     isnull_filter, | ||||
|     contains_filter, | ||||
|     startswith_filter, | ||||
|     endswith_filter, | ||||
| ] | ||||
| text_filter: FilterList = [ | ||||
|     isnull_filter, | ||||
|     contains_filter, | ||||
| ] | ||||
| num_filter: FilterList = [exact_filter, gt_filter, lt_filter] | ||||
| date_filter: FilterList = [ | ||||
|     year_exact_filter, | ||||
|     isnull_filter, | ||||
|     year_gt_filter, | ||||
|     year_lt_filter, | ||||
| ] | ||||
|  | ||||
| conditions = ["and", "or"] | ||||
| session_filters: ModelFilterSet = [ | ||||
|     {"name": char_filter}, | ||||
|     {"timestamp_start": date_filter}, | ||||
|     {"timestamp_end": date_filter}, | ||||
|     {"duration_manual": num_filter}, | ||||
|     {"duration_calculated": num_filter}, | ||||
|     {"note": text_filter}, | ||||
|     {"device": char_filter}, | ||||
|     {"created_at": date_filter}, | ||||
|     {"modified_at": date_filter}, | ||||
| ] | ||||
| name_contains_age: FieldFilter = { | ||||
|     "filtered_field": "name", | ||||
|     "filtered_value": "age", | ||||
|     "filter": contains_filter, | ||||
| } | ||||
| simple_example_filter: FilterSet = [name_contains_age] | ||||
| timestamp_start_year_2024: FieldFilter = { | ||||
|     "filtered_field": "timestamp_start", | ||||
|     "filtered_value": "2024", | ||||
|     "filter": year_exact_filter, | ||||
| } | ||||
| physical_only: FieldFilter = { | ||||
|     "filtered_field": "purchase__ownership_type", | ||||
|     "filtered_value": "ph", | ||||
|     "filter": exact_filter, | ||||
| } | ||||
|  | ||||
|  | ||||
| def negate_filter(filter: FieldFilter) -> FieldFilter: | ||||
|     return {**filter, "negated": True} | ||||
|  | ||||
|  | ||||
| without_physical: FieldFilter = negate_filter(physical_only) | ||||
| combined_example_filter: FilterSet = [name_contains_age, timestamp_start_year_2024] | ||||
| combined_with_negated_example_filter = [timestamp_start_year_2024, without_physical] | ||||
|  | ||||
|  | ||||
| def string_to_dict(s: str) -> dict[str, str]: | ||||
|     key, value = s.split("=") | ||||
|     return {key: value} | ||||
|  | ||||
|  | ||||
| def create_django_filter_dict( | ||||
|     filter: Filter, field: str, filtered_value: str | ||||
| ) -> dict[str, str]: | ||||
|     """ | ||||
|     Creates a dict that can be used with the Django | ||||
|     filter function by unpacking it: | ||||
|     Model.objects.filter(**return_value) | ||||
|     """ | ||||
|     if not is_filter(filter): | ||||
|         raise ValueError("filter is not of type Filter") | ||||
|     return {f"{field}{filter["filter_string"]}": filtered_value} | ||||
|  | ||||
|  | ||||
| def join_filter_with_condition(filters: FilterSet, condition: str): | ||||
|     if not is_filterset(filters): | ||||
|         raise ValueError("filters is not FilterSet") | ||||
|     conditions = {"AND": operator.and_, "OR": operator.or_, "XOR": operator.xor} | ||||
|     condition = condition.upper() | ||||
|     if condition not in conditions: | ||||
|         raise ValueError(f"Condition '{condition}' not one of '{conditions.keys()}'.") | ||||
|     q_objects: list[Q] = [] | ||||
|     for filter_item in filters: | ||||
|         q = Q( | ||||
|             **create_django_filter_dict( | ||||
|                 filter_item["filter"], | ||||
|                 filter_item["filtered_field"], | ||||
|                 filter_item["filtered_value"], | ||||
|             ) | ||||
|         ) | ||||
|         if filter_item.get("negated", False): | ||||
|             q = ~q | ||||
|         q_objects.append(q) | ||||
|     return reduce(conditions[condition], q_objects) | ||||
|  | ||||
|  | ||||
| def apply_filters( | ||||
|     filters: FilterSet, | ||||
|     queryset: QuerySet[Any], | ||||
| ) -> QuerySet[Any] | None: | ||||
|     if len(filters) == 0: | ||||
|         return queryset | ||||
|     if type(filters) is not list: | ||||
|         raise ValueError("filters argument not of type list") | ||||
|     # TODO: modify FilterSet so it includes the condition to use | ||||
|     # so we can remove the hard-coding of "AND" here | ||||
|     return queryset.filter(join_filter_with_condition(filters, "AND")) | ||||
|  | ||||
|  | ||||
| def filters_to_string(filters: FilterSet) -> str: | ||||
|     constructed_filters: list[dict[str, str | bool]] = [] | ||||
|     for filter in filters: | ||||
|         constructed_filters.append( | ||||
|             { | ||||
|                 "id": filter["filter"]["filter_id"], | ||||
|                 "field": filter["filtered_field"], | ||||
|                 "value": filter["filtered_value"], | ||||
|                 "negated": filter.get("negated", False), | ||||
|             } | ||||
|         ) | ||||
|     return json_dumps(constructed_filters) | ||||
|  | ||||
|  | ||||
| def string_to_filters(filter_string: str) -> FilterSet: | ||||
|     obj = json_loads(filter_string) | ||||
|     filters = [ | ||||
|         { | ||||
|             "filter": defined_filters_list[item["id"]], | ||||
|             "filtered_field": item["field"], | ||||
|             "filtered_value": item["value"], | ||||
|             "negated": item.get("negated", False), | ||||
|         } | ||||
|         for item in obj | ||||
|     ] | ||||
|     if not is_filterset(filters): | ||||
|         raise ValueError("filters is not of type FilterSet") | ||||
|     return filters | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def list_sessions(request: HttpRequest) -> HttpResponse: | ||||
|     context: dict[Any, Any] = {} | ||||
|     page_number = request.GET.get("page", 1) | ||||
|     limit = request.GET.get("limit", 10) | ||||
|     filters = request.GET.get("filters", "") | ||||
|     sessions = Session.objects.order_by("-timestamp_start") | ||||
|     if filters != "": | ||||
|         filter_obj = string_to_filters(filters) | ||||
|         sessions = apply_filters(filter_obj, queryset=sessions) | ||||
|     page_obj = None | ||||
|     if int(limit) != 0: | ||||
|         paginator = Paginator(sessions, limit) | ||||
|         page_obj = paginator.get_page(page_number) | ||||
|         sessions = page_obj.object_list | ||||
|  | ||||
|     context = { | ||||
|         "title": "Manage sessions", | ||||
|         "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 session"), url="add_session"), | ||||
|             "columns": [ | ||||
|                 "Name", | ||||
|                 "Date", | ||||
|                 "Duration", | ||||
|                 "Duration (manual)", | ||||
|                 "Device", | ||||
|                 "Created", | ||||
|                 "Actions", | ||||
|             ], | ||||
|             "rows": [ | ||||
|                 [ | ||||
|                     A( | ||||
|                         children=truncate_with_popover(session.purchase.edition.name), | ||||
|                         url=reverse( | ||||
|                             "view_game", | ||||
|                             args=[session.purchase.edition.game.pk], | ||||
|                         ), | ||||
|                     ), | ||||
|                     f"{session.timestamp_start.strftime(datetimeformat)}{f" — {session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}", | ||||
|                     ( | ||||
|                         format_duration(session.duration_calculated, durationformat) | ||||
|                         if session.duration_calculated | ||||
|                         else "-" | ||||
|                     ), | ||||
|                     ( | ||||
|                         format_duration(session.duration_manual, durationformat_manual) | ||||
|                         if session.duration_manual | ||||
|                         else "-" | ||||
|                     ), | ||||
|                     session.device, | ||||
|                     session.created_at.strftime(dateformat), | ||||
|                     render_to_string( | ||||
|                         "cotton/button_group_sm.html", | ||||
|                         { | ||||
|                             "buttons": [ | ||||
|                                 { | ||||
|                                     "href": reverse("edit_session", args=[session.pk]), | ||||
|                                     "text": "Edit", | ||||
|                                     "color": "gray", | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     "href": reverse( | ||||
|                                         "delete_session", args=[session.pk] | ||||
|                                     ), | ||||
|                                     "text": "Delete", | ||||
|                                     "color": "red", | ||||
|                                 }, | ||||
|                             ] | ||||
|                         }, | ||||
|                     ), | ||||
|                 ] | ||||
|                 for session in sessions | ||||
|             ], | ||||
|         }, | ||||
|     } | ||||
|     return render(request, "list_purchases.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse: | ||||
|     context = {} | ||||
|     initial: dict[str, Any] = {"timestamp_start": timezone.now()} | ||||
|  | ||||
|     last = Session.objects.last() | ||||
|     if last != None: | ||||
|         initial["purchase"] = last.purchase | ||||
|  | ||||
|     if request.method == "POST": | ||||
|         form = SessionForm(request.POST or None, initial=initial) | ||||
|         if form.is_valid(): | ||||
|             form.save() | ||||
|             return redirect("list_sessions") | ||||
|     else: | ||||
|         if purchase_id: | ||||
|             purchase = Purchase.objects.get(id=purchase_id) | ||||
|             form = SessionForm( | ||||
|                 initial={ | ||||
|                     **initial, | ||||
|                     "purchase": purchase, | ||||
|                 } | ||||
|             ) | ||||
|         else: | ||||
|             form = SessionForm(initial=initial) | ||||
|  | ||||
|     context["title"] = "Add New Session" | ||||
|     context["form"] = form | ||||
|     return render(request, "add_session.html", context) | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def edit_session(request: HttpRequest, session_id: int) -> HttpResponse: | ||||
|     context = {} | ||||
|     session = get_object_or_404(Session, id=session_id) | ||||
|     form = SessionForm(request.POST or None, instance=session) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|     context["title"] = "Edit Session" | ||||
|     context["form"] = form | ||||
|     return render(request, "add_session.html", context) | ||||
|  | ||||
|  | ||||
| def clone_session_by_id(session_id: int) -> Session: | ||||
|     session = get_object_or_404(Session, id=session_id) | ||||
|     clone = session | ||||
|     clone.pk = None | ||||
|     clone.timestamp_start = timezone.now() | ||||
|     clone.timestamp_end = None | ||||
|     clone.note = "" | ||||
|     clone.save() | ||||
|     return clone | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def new_session_from_existing_session( | ||||
|     request: HttpRequest, session_id: int, template: str = "" | ||||
| ) -> HttpResponse: | ||||
|     session = clone_session_by_id(session_id) | ||||
|     if request.htmx: | ||||
|         context = { | ||||
|             "session": session, | ||||
|             "session_count": int(request.GET.get("session_count", 0)) + 1, | ||||
|         } | ||||
|         return render(request, template, context) | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| @use_custom_redirect | ||||
| def end_session( | ||||
|     request: HttpRequest, session_id: int, template: str = "" | ||||
| ) -> HttpResponse: | ||||
|     session = get_object_or_404(Session, id=session_id) | ||||
|     session.timestamp_end = timezone.now() | ||||
|     session.save() | ||||
|     if request.htmx: | ||||
|         context = { | ||||
|             "session": session, | ||||
|             "session_count": request.GET.get("session_count", 0), | ||||
|         } | ||||
|         return render(request, template, context) | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| @login_required | ||||
| def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse: | ||||
|     session = get_object_or_404(Session, id=session_id) | ||||
|     session.delete() | ||||
|     return redirect("list_sessions") | ||||
| @ -1,5 +1,6 @@ | ||||
| #!/usr/bin/env python | ||||
| """Django's command-line utility for administrative tasks.""" | ||||
|  | ||||
| import os | ||||
| import sys | ||||
|  | ||||
|  | ||||
							
								
								
									
										308
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										308
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @ -29,48 +29,25 @@ files = [ | ||||
| tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] | ||||
|  | ||||
| [[package]] | ||||
| name = "black" | ||||
| version = "24.4.2" | ||||
| description = "The uncompromising code formatter." | ||||
| name = "beautifulsoup4" | ||||
| version = "4.12.3" | ||||
| description = "Screen-scraping library" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| python-versions = ">=3.6.0" | ||||
| files = [ | ||||
|     {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, | ||||
|     {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, | ||||
|     {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, | ||||
|     {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, | ||||
|     {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, | ||||
|     {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, | ||||
|     {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, | ||||
|     {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, | ||||
|     {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, | ||||
|     {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, | ||||
|     {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, | ||||
|     {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, | ||||
|     {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, | ||||
|     {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, | ||||
|     {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, | ||||
|     {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, | ||||
|     {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, | ||||
|     {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, | ||||
|     {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, | ||||
|     {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, | ||||
|     {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, | ||||
|     {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, | ||||
|     {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, | ||||
|     {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| click = ">=8.0.0" | ||||
| mypy-extensions = ">=0.4.3" | ||||
| packaging = ">=22.0" | ||||
| pathspec = ">=0.9.0" | ||||
| platformdirs = ">=2" | ||||
| soupsieve = ">1.2" | ||||
|  | ||||
| [package.extras] | ||||
| colorama = ["colorama (>=0.4.3)"] | ||||
| d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] | ||||
| jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] | ||||
| uvloop = ["uvloop (>=0.15.2)"] | ||||
| cchardet = ["cchardet"] | ||||
| chardet = ["chardet"] | ||||
| charset-normalizer = ["charset-normalizer"] | ||||
| html5lib = ["html5lib"] | ||||
| lxml = ["lxml"] | ||||
|  | ||||
| [[package]] | ||||
| name = "cfgv" | ||||
| @ -136,17 +113,17 @@ files = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "django" | ||||
| version = "5.0.7" | ||||
| version = "5.1" | ||||
| description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." | ||||
| optional = false | ||||
| python-versions = ">=3.10" | ||||
| files = [ | ||||
|     {file = "Django-5.0.7-py3-none-any.whl", hash = "sha256:f216510ace3de5de01329463a315a629f33480e893a9024fc93d8c32c22913da"}, | ||||
|     {file = "Django-5.0.7.tar.gz", hash = "sha256:bd4505cae0b9bd642313e8fb71810893df5dc2ffcacaa67a33af2d5cd61888f2"}, | ||||
|     {file = "Django-5.1-py3-none-any.whl", hash = "sha256:d3b811bf5371a26def053d7ee42a9df1267ef7622323fe70a601936725aa4557"}, | ||||
|     {file = "Django-5.1.tar.gz", hash = "sha256:848a5980e8efb76eea70872fb0e4bc5e371619c70fffbe48e3e1b50b2c09455d"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| asgiref = ">=3.7.0,<4" | ||||
| asgiref = ">=3.8.1,<4" | ||||
| sqlparse = ">=0.3.1" | ||||
| tzdata = {version = "*", markers = "sys_platform == \"win32\""} | ||||
|  | ||||
| @ -154,6 +131,20 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} | ||||
| argon2 = ["argon2-cffi (>=19.1.0)"] | ||||
| bcrypt = ["bcrypt"] | ||||
|  | ||||
| [[package]] | ||||
| name = "django-cotton" | ||||
| version = "0.9.34" | ||||
| description = "Bringing component based design to Django templates." | ||||
| optional = false | ||||
| python-versions = "<4,>=3.8" | ||||
| files = [ | ||||
|     {file = "django_cotton-0.9.34-py3-none-any.whl", hash = "sha256:9721dd79066d5a28eefb84527bea7f2daa8f1db6111ffdecbc5dd64fe2e300c9"}, | ||||
|     {file = "django_cotton-0.9.34.tar.gz", hash = "sha256:3f2d950a9ad0985955ca0fb2d5fbb42be1f07f55239864fe5a1d0e873303f0bd"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| beautifulsoup4 = ">=4.12.2,<4.13.0" | ||||
|  | ||||
| [[package]] | ||||
| name = "django-debug-toolbar" | ||||
| version = "4.4.6" | ||||
| @ -185,13 +176,13 @@ Django = ">=3.2" | ||||
|  | ||||
| [[package]] | ||||
| name = "django-htmx" | ||||
| version = "1.18.0" | ||||
| version = "1.19.0" | ||||
| description = "Extensions for using Django with htmx." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "django_htmx-1.18.0-py3-none-any.whl", hash = "sha256:48f3b8a784467bfcc30562067b87ecbc4ad9b739cc269eec5f3789c16d2cb2ce"}, | ||||
|     {file = "django_htmx-1.18.0.tar.gz", hash = "sha256:db8a0cc15bcd0f7ae929bcb9108d9e6be228843092aca7956d977c31c4d95aae"}, | ||||
|     {file = "django_htmx-1.19.0-py3-none-any.whl", hash = "sha256:875a642814e52278c1728842436beda2001847a493ab79fd82da3fb46ead140f"}, | ||||
|     {file = "django_htmx-1.19.0.tar.gz", hash = "sha256:e7e17304e78e07f96eca0affc3ce1806edfdf3538bb7cb1912452b101f3e627d"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -483,43 +474,43 @@ testing = ["coverage", "pyyaml"] | ||||
|  | ||||
| [[package]] | ||||
| name = "mypy" | ||||
| version = "1.10.1" | ||||
| version = "1.11.1" | ||||
| description = "Optional static typing for Python" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"}, | ||||
|     {file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"}, | ||||
|     {file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"}, | ||||
|     {file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"}, | ||||
|     {file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"}, | ||||
|     {file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"}, | ||||
|     {file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"}, | ||||
|     {file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"}, | ||||
|     {file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"}, | ||||
|     {file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"}, | ||||
|     {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, | ||||
|     {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, | ||||
|     {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, | ||||
|     {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, | ||||
|     {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, | ||||
|     {file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"}, | ||||
|     {file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"}, | ||||
|     {file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"}, | ||||
|     {file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"}, | ||||
|     {file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"}, | ||||
|     {file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"}, | ||||
|     {file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"}, | ||||
|     {file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"}, | ||||
|     {file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"}, | ||||
|     {file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"}, | ||||
|     {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, | ||||
|     {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, | ||||
|     {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, | ||||
|     {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, | ||||
|     {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, | ||||
|     {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, | ||||
|     {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, | ||||
|     {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, | ||||
|     {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, | ||||
|     {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, | ||||
|     {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, | ||||
|     {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, | ||||
|     {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, | ||||
|     {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, | ||||
|     {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, | ||||
|     {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, | ||||
|     {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, | ||||
|     {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, | ||||
|     {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, | ||||
|     {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, | ||||
|     {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, | ||||
|     {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, | ||||
|     {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, | ||||
|     {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, | ||||
|     {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, | ||||
|     {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, | ||||
|     {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, | ||||
|     {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, | ||||
|     {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| mypy-extensions = ">=1.0.0" | ||||
| typing-extensions = ">=4.1.0" | ||||
| typing-extensions = ">=4.6.0" | ||||
|  | ||||
| [package.extras] | ||||
| dmypy = ["psutil (>=4.0)"] | ||||
| @ -604,13 +595,13 @@ testing = ["pytest", "pytest-benchmark"] | ||||
|  | ||||
| [[package]] | ||||
| name = "pre-commit" | ||||
| version = "3.7.1" | ||||
| version = "3.8.0" | ||||
| description = "A framework for managing and maintaining multi-language pre-commit hooks." | ||||
| optional = false | ||||
| python-versions = ">=3.9" | ||||
| files = [ | ||||
|     {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, | ||||
|     {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, | ||||
|     {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"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -638,82 +629,84 @@ test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark", | ||||
|  | ||||
| [[package]] | ||||
| name = "pytest" | ||||
| version = "8.2.2" | ||||
| version = "8.3.2" | ||||
| description = "pytest: simple powerful testing with Python" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, | ||||
|     {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, | ||||
|     {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, | ||||
|     {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| colorama = {version = "*", markers = "sys_platform == \"win32\""} | ||||
| iniconfig = "*" | ||||
| packaging = "*" | ||||
| pluggy = ">=1.5,<2.0" | ||||
| pluggy = ">=1.5,<2" | ||||
|  | ||||
| [package.extras] | ||||
| dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] | ||||
|  | ||||
| [[package]] | ||||
| name = "pyyaml" | ||||
| version = "6.0.1" | ||||
| version = "6.0.2" | ||||
| description = "YAML parser and emitter for Python" | ||||
| optional = false | ||||
| python-versions = ">=3.6" | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, | ||||
|     {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, | ||||
|     {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, | ||||
|     {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, | ||||
|     {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, | ||||
|     {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, | ||||
|     {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, | ||||
|     {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, | ||||
|     {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, | ||||
|     {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, | ||||
|     {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, | ||||
|     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, | ||||
|     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, | ||||
|     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, | ||||
|     {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, | ||||
|     {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, | ||||
|     {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, | ||||
|     {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, | ||||
|     {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, | ||||
|     {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, | ||||
|     {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, | ||||
|     {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, | ||||
|     {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, | ||||
|     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, | ||||
|     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, | ||||
|     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, | ||||
|     {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, | ||||
|     {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, | ||||
|     {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, | ||||
|     {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, | ||||
|     {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, | ||||
|     {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"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, | ||||
|     {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, | ||||
|     {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, | ||||
|     {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, | ||||
|     {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, | ||||
|     {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, | ||||
|     {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, | ||||
|     {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, | ||||
|     {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, | ||||
|     {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, | ||||
|     {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, | ||||
|     {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, | ||||
|     {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, | ||||
|     {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -830,31 +823,25 @@ files = [ | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "slippers" | ||||
| version = "0.6.2" | ||||
| description = "Build reusable components in Django without writing a single line of Python." | ||||
| name = "soupsieve" | ||||
| version = "2.6" | ||||
| description = "A modern CSS selector implementation for Beautiful Soup." | ||||
| optional = false | ||||
| python-versions = ">=3.8.0" | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "slippers-0.6.2-py3-none-any.whl", hash = "sha256:739e05f85354becbf0a65daab831eea62557d89e7512042209ab629af4378bca"}, | ||||
|     {file = "slippers-0.6.2.tar.gz", hash = "sha256:4cb555b8822ba0d404e5405723f5d723994022c29046008ee917081031bc0cf1"}, | ||||
|     {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, | ||||
|     {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| Django = ">=3.2" | ||||
| PyYAML = ">=5.4.0" | ||||
| typeguard = ">=2.13.3,<3.0.0" | ||||
| typing-extensions = ">=4.4.0" | ||||
|  | ||||
| [[package]] | ||||
| name = "sqlparse" | ||||
| version = "0.5.0" | ||||
| version = "0.5.1" | ||||
| description = "A non-validating SQL parser." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, | ||||
|     {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, | ||||
|     {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, | ||||
|     {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, | ||||
| ] | ||||
|  | ||||
| [package.extras] | ||||
| @ -874,13 +861,13 @@ files = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "tqdm" | ||||
| version = "4.66.4" | ||||
| version = "4.66.5" | ||||
| description = "Fast, Extensible Progress Meter" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| files = [ | ||||
|     {file = "tqdm-4.66.4-py3-none-any.whl", hash = "sha256:b75ca56b413b030bc3f00af51fd2c1a1a5eac6a0c1cca83cbb37a5c52abce644"}, | ||||
|     {file = "tqdm-4.66.4.tar.gz", hash = "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb"}, | ||||
|     {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, | ||||
|     {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -892,21 +879,6 @@ notebook = ["ipywidgets (>=6)"] | ||||
| slack = ["slack-sdk"] | ||||
| telegram = ["requests"] | ||||
|  | ||||
| [[package]] | ||||
| name = "typeguard" | ||||
| version = "2.13.3" | ||||
| description = "Run-time type checker for Python" | ||||
| optional = false | ||||
| python-versions = ">=3.5.3" | ||||
| files = [ | ||||
|     {file = "typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1"}, | ||||
|     {file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"}, | ||||
| ] | ||||
|  | ||||
| [package.extras] | ||||
| doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] | ||||
| test = ["mypy", "pytest", "typing-extensions"] | ||||
|  | ||||
| [[package]] | ||||
| name = "typing-extensions" | ||||
| version = "4.12.2" | ||||
| @ -931,13 +903,13 @@ files = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "uvicorn" | ||||
| version = "0.30.1" | ||||
| version = "0.30.5" | ||||
| description = "The lightning-fast ASGI server." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "uvicorn-0.30.1-py3-none-any.whl", hash = "sha256:cd17daa7f3b9d7a24de3617820e634d0933b69eed8e33a516071174427238c81"}, | ||||
|     {file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"}, | ||||
|     {file = "uvicorn-0.30.5-py3-none-any.whl", hash = "sha256:b2d86de274726e9878188fa07576c9ceeff90a839e2b6e25c917fe05f5a6c835"}, | ||||
|     {file = "uvicorn-0.30.5.tar.gz", hash = "sha256:ac6fdbd4425c5fd17a9fe39daf4d4d075da6fdc80f653e5894cdc2fd98752bee"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| @ -970,4 +942,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess | ||||
| [metadata] | ||||
| lock-version = "2.0" | ||||
| python-versions = "^3.11" | ||||
| content-hash = "ca9188453f62ec4470e2b2f8bc1e4a17ad421272f401328f82d6fede231dc737" | ||||
| content-hash = "f96f55c381f25a4a473be8d53ef83d13c70bb7c731b5132f4ece2af1b3d3afed" | ||||
|  | ||||
| @ -8,7 +8,6 @@ readme = "README.md" | ||||
| packages = [{include = "timetracker"}] | ||||
|  | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| black = "^24.4.2" | ||||
| mypy = "^1.10.1" | ||||
| pyyaml = "^6.0.1" | ||||
| pytest = "^8.2.2" | ||||
| @ -31,7 +30,7 @@ django-template-partials = "^24.2" | ||||
| markdown = "^3.6" | ||||
|  | ||||
|  | ||||
| slippers = "^0.6.2" | ||||
| django-cotton = "^0.9.34" | ||||
| [tool.isort] | ||||
| profile = "black" | ||||
|  | ||||
|  | ||||
							
								
								
									
										18
									
								
								shell.nix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								shell.nix
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| { | ||||
|   pkgs ? import <nixpkgs> { }, | ||||
| }: | ||||
|  | ||||
| pkgs.mkShell { | ||||
|   buildInputs = with pkgs; [ | ||||
|     nodejs | ||||
|     python3 | ||||
|     poetry | ||||
|     ruff | ||||
|   ]; | ||||
|  | ||||
|   shellHook = '' | ||||
|     python -m venv .venv | ||||
|     . .venv/bin/activate | ||||
|     poetry install | ||||
|   ''; | ||||
| } | ||||
| @ -41,7 +41,7 @@ INSTALLED_APPS = [ | ||||
|     "template_partials", | ||||
|     "graphene_django", | ||||
|     "django_htmx", | ||||
|     "slippers", | ||||
|     "django_cotton", | ||||
| ] | ||||
|  | ||||
| GRAPHENE = {"SCHEMA": "games.schema.schema"} | ||||
| @ -83,12 +83,10 @@ TEMPLATES = [ | ||||
|                 "django.template.context_processors.request", | ||||
|                 "django.contrib.auth.context_processors.auth", | ||||
|                 "django.contrib.messages.context_processors.messages", | ||||
|                 "games.views.model_counts", | ||||
|                 "games.views.stats_dropdown_year_range", | ||||
|                 "games.views.general.model_counts", | ||||
|             ], | ||||
|             "builtins": [ | ||||
|                 "template_partials.templatetags.partials", | ||||
|                 "slippers.templatetags.slippers", | ||||
|             ], | ||||
|         }, | ||||
|     }, | ||||
|  | ||||
| @ -13,6 +13,7 @@ Including another URLconf | ||||
|     1. Import the include() function: from django.urls import include, path | ||||
|     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls')) | ||||
| """ | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib import admin | ||||
| from django.contrib.auth import views as auth_views | ||||
|  | ||||
		Reference in New Issue
	
	Block a user