Compare commits
	
		
			5 Commits
		
	
	
		
			8e1c670ffd
			...
			b77089f7ad
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b77089f7ad | |||
| 24f4459318 | |||
| 751182df52 | |||
| 33e136a810 | |||
| 362732c22a | 
| @ -1 +1,5 @@ | |||||||
| src/web/static/* | src/web/static/* | ||||||
|  | .venv | ||||||
|  | .githooks | ||||||
|  | .vscode | ||||||
|  | node_modules | ||||||
| @ -1,3 +1,7 @@ | |||||||
|  | ## Unreleased | ||||||
|  |  | ||||||
|  | * Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6) | ||||||
|  |  | ||||||
| ## 0.1.4 / 2023-01-08 15:45+01:00 | ## 0.1.4 / 2023-01-08 15:45+01:00 | ||||||
|  |  | ||||||
| * Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23) | * Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23) | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ RUN npm install && \ | |||||||
|  |  | ||||||
| FROM python:3.10.9-alpine | FROM python:3.10.9-alpine | ||||||
|  |  | ||||||
| ENV VERSION_NUMBER 0.1.4 | ENV VERSION_NUMBER 0.1.2-7-g24f4459 | ||||||
| ENV PROD 1 | ENV PROD 1 | ||||||
|  |  | ||||||
| RUN apk add \ | RUN apk add \ | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
									
									
									
									
								
							| @ -56,7 +56,7 @@ sethookdir: | |||||||
| 	git config core.hooksPath .githooks | 	git config core.hooksPath .githooks | ||||||
|  |  | ||||||
| date: | date: | ||||||
| 	python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' | 	poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' | ||||||
|  |  | ||||||
| cleanstatic: | cleanstatic: | ||||||
| 	rm -r src/web/static/* | 	rm -r src/web/static/* | ||||||
|  | |||||||
| @ -10,4 +10,4 @@ poetry run python src/web/manage.py collectstatic --clear --no-input | |||||||
| echo "Starting server" | echo "Starting server" | ||||||
| caddy start | caddy start | ||||||
| cd src/web || exit | cd src/web || exit | ||||||
| poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker | poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "timetracker" | name = "timetracker" | ||||||
| version = "0.1.4" | version = "0.1.2" | ||||||
| description = "A simple time tracker." | description = "A simple time tracker." | ||||||
| authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] | authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] | ||||||
| license = "GPL" | license = "GPL" | ||||||
|  | |||||||
| @ -8,8 +8,17 @@ def now() -> datetime: | |||||||
|     return datetime.now(ZoneInfo(settings.TIME_ZONE)) |     return datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _safe_timedelta(duration: timedelta | int | None): | ||||||
|  |     if duration == None: | ||||||
|  |         return timedelta(0) | ||||||
|  |     elif isinstance(duration, int): | ||||||
|  |         return timedelta(seconds=duration) | ||||||
|  |     elif isinstance(duration, timedelta): | ||||||
|  |         return duration | ||||||
|  |  | ||||||
|  |  | ||||||
| def format_duration( | def format_duration( | ||||||
|     duration: timedelta, format_string: str = "%H hours %m minutes" |     duration: timedelta | int | None, format_string: str = "%H hours %m minutes" | ||||||
| ) -> str: | ) -> str: | ||||||
|     """ |     """ | ||||||
|     Format timedelta into the specified format_string. |     Format timedelta into the specified format_string. | ||||||
| @ -22,8 +31,8 @@ def format_duration( | |||||||
|     minute_seconds = 60 |     minute_seconds = 60 | ||||||
|     hour_seconds = 60 * minute_seconds |     hour_seconds = 60 * minute_seconds | ||||||
|     day_seconds = 24 * hour_seconds |     day_seconds = 24 * hour_seconds | ||||||
|     if not isinstance(duration, timedelta): |     duration = _safe_timedelta(duration) | ||||||
|         duration = timedelta(seconds=duration) |     # we don't need float | ||||||
|     seconds_total = int(duration.total_seconds()) |     seconds_total = int(duration.total_seconds()) | ||||||
|     # timestamps where end is before start |     # timestamps where end is before start | ||||||
|     if seconds_total < 0: |     if seconds_total < 0: | ||||||
|  | |||||||
| @ -0,0 +1,21 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-01-09 14:49 | ||||||
|  |  | ||||||
|  | import datetime | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("tracker", "0003_alter_session_duration_manual_and_more"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="session", | ||||||
|  |             name="duration_manual", | ||||||
|  |             field=models.DurationField( | ||||||
|  |                 blank=True, default=datetime.timedelta(0), null=True | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										34
									
								
								src/web/tracker/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/web/tracker/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | |||||||
|  | # Generated by Django 4.1.5 on 2023-01-09 17:43 | ||||||
|  |  | ||||||
|  | from django.db import migrations | ||||||
|  | from datetime import timedelta | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def set_duration_calculated_none_to_zero(apps, schema_editor): | ||||||
|  |     Session = apps.get_model("tracker", "Session") | ||||||
|  |     for session in Session.objects.all(): | ||||||
|  |         if session.duration_calculated == None: | ||||||
|  |             session.duration_calculated = timedelta(0) | ||||||
|  |             session.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def revert_set_duration_calculated_none_to_zero(apps, schema_editor): | ||||||
|  |     Session = apps.get_model("tracker", "Session") | ||||||
|  |     for session in Session.objects.all(): | ||||||
|  |         if session.duration_calculated == timedelta(0): | ||||||
|  |             session.duration_calculated = None | ||||||
|  |             session.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("tracker", "0004_alter_session_duration_manual"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.RunPython( | ||||||
|  |             set_duration_calculated_none_to_zero, | ||||||
|  |             revert_set_duration_calculated_none_to_zero, | ||||||
|  |         ) | ||||||
|  |     ] | ||||||
| @ -3,7 +3,7 @@ from datetime import datetime, timedelta | |||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from zoneinfo import ZoneInfo | from zoneinfo import ZoneInfo | ||||||
| from common.util.time import format_duration | from common.util.time import format_duration | ||||||
| from django.db.models import Sum | from django.db.models import Sum, F | ||||||
|  |  | ||||||
|  |  | ||||||
| class Game(models.Model): | class Game(models.Model): | ||||||
| @ -32,69 +32,51 @@ class Platform(models.Model): | |||||||
|         return self.name |         return self.name | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SessionQuerySet(models.QuerySet): | ||||||
|  |     def total_duration(self): | ||||||
|  |         result = self.aggregate( | ||||||
|  |             duration=Sum(F("duration_calculated") + F("duration_manual")) | ||||||
|  |         ) | ||||||
|  |         return format_duration(result["duration"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Session(models.Model): | class Session(models.Model): | ||||||
|     purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) |     purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) | ||||||
|     timestamp_start = models.DateTimeField() |     timestamp_start = models.DateTimeField() | ||||||
|     timestamp_end = models.DateTimeField(blank=True, null=True) |     timestamp_end = models.DateTimeField(blank=True, null=True) | ||||||
|     duration_manual = models.DurationField(blank=True, null=True) |     duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) | ||||||
|     duration_calculated = models.DurationField(blank=True, null=True) |     duration_calculated = models.DurationField(blank=True, null=True) | ||||||
|     note = models.TextField(blank=True, null=True) |     note = models.TextField(blank=True, null=True) | ||||||
|  |  | ||||||
|  |     objects = SessionQuerySet.as_manager() | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         mark = ", manual" if self.duration_manual != None else "" |         mark = ", manual" if self.duration_manual != None else "" | ||||||
|         return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_any()}{mark})" |         return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" | ||||||
|  |  | ||||||
|     def finish_now(self): |     def finish_now(self): | ||||||
|         self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) |         self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||||
|  |  | ||||||
|     def duration_seconds(self) -> timedelta: |     def duration_seconds(self) -> timedelta: | ||||||
|         if self.duration_manual == None: |         manual = timedelta(0) | ||||||
|             if self.timestamp_end == None or self.timestamp_start == None: |         calculated = timedelta(0) | ||||||
|                 return timedelta(0) |         if not self.duration_manual in (None, 0, timedelta(0)): | ||||||
|             else: |             manual = self.duration_manual | ||||||
|                 value = self.timestamp_end - self.timestamp_start |         if self.timestamp_end != None and self.timestamp_start != None: | ||||||
|         else: |             calculated = self.timestamp_end - self.timestamp_start | ||||||
|             value = self.duration_manual |         return timedelta(seconds=(manual + calculated).total_seconds()) | ||||||
|         return timedelta(seconds=value.total_seconds()) |  | ||||||
|  |  | ||||||
|     def duration_formatted(self) -> str: |     def duration_formatted(self) -> str: | ||||||
|         result = format_duration(self.duration_seconds(), "%H:%m") |         result = format_duration(self.duration_seconds(), "%H:%m") | ||||||
|         return result |         return result | ||||||
|  |  | ||||||
|     def duration_any(self): |     @property | ||||||
|         return ( |     def duration_sum(self) -> str: | ||||||
|             self.duration_formatted() |         return Session.objects.all().total_duration() | ||||||
|             if self.duration_manual == None |  | ||||||
|             else self.duration_manual |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def calculated_sum() -> timedelta: |  | ||||||
|         calculated_sum_query = Session.objects.all().aggregate( |  | ||||||
|             Sum("duration_calculated") |  | ||||||
|         ) |  | ||||||
|         calculated_sum = ( |  | ||||||
|             timedelta(0) |  | ||||||
|             if calculated_sum_query["duration_calculated__sum"] == None |  | ||||||
|             else calculated_sum_query["duration_calculated__sum"] |  | ||||||
|         ) |  | ||||||
|         return calculated_sum |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def manual_sum() -> timedelta: |  | ||||||
|         manual_sum_query = Session.objects.all().aggregate(Sum("duration_manual")) |  | ||||||
|         manual_sum = ( |  | ||||||
|             timedelta(0) |  | ||||||
|             if manual_sum_query["duration_manual__sum"] == None |  | ||||||
|             else manual_sum_query["duration_manual__sum"] |  | ||||||
|         ) |  | ||||||
|         return manual_sum |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def total_sum() -> timedelta: |  | ||||||
|         return Session.manual_sum() + Session.calculated_sum() |  | ||||||
|  |  | ||||||
|     def save(self, *args, **kwargs): |     def save(self, *args, **kwargs): | ||||||
|         if self.timestamp_start != None and self.timestamp_end != None: |         if self.timestamp_start != None and self.timestamp_end != None: | ||||||
|             self.duration_calculated = self.timestamp_end - self.timestamp_start |             self.duration_calculated = self.timestamp_end - self.timestamp_start | ||||||
|  |         else: | ||||||
|  |             self.duration_calculated = timedelta(0) | ||||||
|         super(Session, self).save(*args, **kwargs) |         super(Session, self).save(*args, **kwargs) | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ | |||||||
| {% block content %} | {% block content %} | ||||||
|     {% if purchase %} |     {% if purchase %} | ||||||
|         <div class="text-center text-xl mb-4 dark:text-slate-400"> |         <div class="text-center text-xl mb-4 dark:text-slate-400"> | ||||||
|             <h1>Listing sessions only for purchase "{{ purchase }}"</h1> |             <h1>Listing sessions only for purchaseee "{{ purchase }}" (total playtime: {{ total_duration }})</h1> | ||||||
|             <a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a> |             <a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a> | ||||||
|         </div> |         </div> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  | |||||||
| @ -60,6 +60,7 @@ def list_sessions(request, purchase_id=None): | |||||||
|             session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) |             session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||||
|             session.unfinished = True |             session.unfinished = True | ||||||
|  |  | ||||||
|  |     context["total_duration"] = dataset.total_duration() | ||||||
|     context["dataset"] = dataset |     context["dataset"] = dataset | ||||||
|  |  | ||||||
|     return render(request, "list_sessions.html", context) |     return render(request, "list_sessions.html", context) | ||||||
| @ -105,12 +106,6 @@ def add_platform(request): | |||||||
|  |  | ||||||
| def index(request): | def index(request): | ||||||
|     context = {} |     context = {} | ||||||
|     if Session.objects.count() == 0: |     context["total_duration"] = Session().duration_sum | ||||||
|         duration: str = "" |  | ||||||
|     else: |  | ||||||
|         context["total_duration"] = format_duration( |  | ||||||
|             Session.total_sum(), |  | ||||||
|             "%H hours %m minutes", |  | ||||||
|         ) |  | ||||||
|     context["title"] = "Index" |     context["title"] = "Index" | ||||||
|     return render(request, "index.html", context) |     return render(request, "index.html", context) | ||||||
|  | |||||||
| @ -56,3 +56,13 @@ class FormatDurationTest(unittest.TestCase): | |||||||
|         delta = timedelta(hours=-2) |         delta = timedelta(hours=-2) | ||||||
|         result = format_duration(delta, "%H hours") |         result = format_duration(delta, "%H hours") | ||||||
|         self.assertEqual(result, "0 hours") |         self.assertEqual(result, "0 hours") | ||||||
|  |  | ||||||
|  |     def test_none(self): | ||||||
|  |         try: | ||||||
|  |             format_duration(None) | ||||||
|  |         except TypeError as exc: | ||||||
|  |             assert False, f"format_duration(None) raised an exception {exc}" | ||||||
|  |  | ||||||
|  |     def test_number(self): | ||||||
|  |         self.assertEqual(format_duration(3600, "%H hour"), "1 hour") | ||||||
|  |  | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user