From b5ddcb76aa39116238bbd143628baa0d2db7b4f2 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 26 May 2026 12:59:43 +0330 Subject: [PATCH] fix(timezone): fix timer clock-skew --- apps/time_entries/api/serializers.py | 25 ++++++++++++++++ apps/time_entries/api/views.py | 40 +++++++++++++++++++++---- apps/time_entries/tests/test_views.py | 42 ++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/apps/time_entries/api/serializers.py b/apps/time_entries/api/serializers.py index 01b987b..d388a8a 100644 --- a/apps/time_entries/api/serializers.py +++ b/apps/time_entries/api/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from django.utils import timezone from core.serializers.base import BaseModelSerializer from apps.time_entries.models import TimeEntry @@ -31,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer): tag_details = serializers.SerializerMethodField() start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True) + start_time_ms = serializers.SerializerMethodField() + end_time_ms = serializers.SerializerMethodField() + server_now_ms = serializers.SerializerMethodField() + + @staticmethod + def _epoch_ms(value): + if value is None: + return None + if timezone.is_naive(value): + value = timezone.make_aware(value, timezone.get_current_timezone()) + return int(value.timestamp() * 1000) + + def get_start_time_ms(self, obj): + return self._epoch_ms(obj.start_time) + + def get_end_time_ms(self, obj): + return self._epoch_ms(obj.end_time) + + def get_server_now_ms(self, obj): + server_now = self.context.get("server_now") or timezone.now() + return self._epoch_ms(server_now) def get_tags(self, obj): return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")] @@ -76,7 +98,10 @@ class TimeEntrySerializer(BaseModelSerializer): "project_details", "description", "start_time", + "start_time_ms", "end_time", + "end_time_ms", + "server_now_ms", "duration", "tags", "tag_details", diff --git a/apps/time_entries/api/views.py b/apps/time_entries/api/views.py index eb3ffcf..3c2f517 100644 --- a/apps/time_entries/api/views.py +++ b/apps/time_entries/api/views.py @@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet): filterset_class = TimeEntryFilter search_fields = ["description", "project__name", "project__client__name", "tags__name"] + @staticmethod + def _epoch_ms(value): + if timezone.is_naive(value): + value = timezone.make_aware(value, timezone.get_current_timezone()) + return int(value.timestamp() * 1000) + + def _serializer_context(self, *, server_now=None): + context = self.get_serializer_context() + context["server_now"] = server_now or timezone.now() + return context + @staticmethod def _serialize_duration_ms(entry): if entry.duration is not None: @@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet): days_since_sunday = (local_dt.weekday() + 1) % 7 return (local_dt - timedelta(days=days_since_sunday)).date() - def _build_grouped_entries(self, entries): - serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data + def _build_grouped_entries(self, entries, *, server_now): + serialized_entries = TimeEntrySerializer( + entries, + many=True, + context=self._serializer_context(server_now=server_now), + ).data serialized_by_id = {item["id"]: item for item in serialized_entries} weeks = [] weeks_by_key = {} @@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet): queryset = self.filter_queryset(self.get_queryset()) paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request, view=self) + server_now = timezone.now() current_items_count = len(page) has_more = (paginator.offset + current_items_count) < paginator.count @@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet): "offset": paginator.offset, "next_offset": paginator.offset + current_items_count if has_more else None, "has_more": has_more, - "groups": self._build_grouped_entries(page), + "server_now_ms": self._epoch_ms(server_now), + "server_now": server_now.isoformat(), + "groups": self._build_grouped_entries(page, server_now=server_now), + } + ) + + @action(detail=False, methods=["get"], url_path="debug-time") + def debug_time(self, request): + server_now = timezone.now() + return Response( + { + "server_now_ms": self._epoch_ms(server_now), + "server_now": server_now.isoformat(), } ) @@ -148,7 +176,7 @@ class TimeEntryViewSet(ModelViewSet): **serializer.validated_data ) - output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context()) + output_serializer = TimeEntrySerializer(entry, context=self._serializer_context()) return Response(output_serializer.data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): @@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet): **serializer.validated_data ) - output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context()) + output_serializer = TimeEntrySerializer(updated_entry, context=self._serializer_context()) return Response(output_serializer.data, status=status.HTTP_200_OK) @action(detail=True, methods=["post"]) @@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet): end_time = serializer.validated_data.get("end_time") stopped_entry = stop_time_entry(entry, end_time=end_time) - output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context()) + output_serializer = TimeEntrySerializer(stopped_entry, context=self._serializer_context()) return Response(output_serializer.data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): diff --git a/apps/time_entries/tests/test_views.py b/apps/time_entries/tests/test_views.py index 6116241..a4544a3 100644 --- a/apps/time_entries/tests/test_views.py +++ b/apps/time_entries/tests/test_views.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from django.utils import timezone from rest_framework.test import APITestCase @@ -40,6 +40,9 @@ class TimeEntryViewTests(APITestCase): self.assertIsNone(entry.end_time) self.assertGreaterEqual(entry.start_time, before) self.assertLessEqual(entry.start_time, after) + self.assertIsInstance(response.data["start_time_ms"], int) + self.assertIsNone(response.data["end_time_ms"]) + self.assertIsInstance(response.data["server_now_ms"], int) def test_time_entry_list_returns_grouped_payload_for_ended_entries(self): user = User.objects.create_user(mobile="09126666666", password="secret123") @@ -72,6 +75,8 @@ class TimeEntryViewTests(APITestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["current_page_items_count"], 1) + self.assertIsInstance(response.data["server_now_ms"], int) + self.assertIn("server_now", response.data) self.assertFalse(response.data["has_more"]) self.assertEqual(len(response.data["groups"]), 1) self.assertEqual(len(response.data["groups"][0]["days"]), 1) @@ -79,6 +84,41 @@ class TimeEntryViewTests(APITestCase): response.data["groups"][0]["days"][0]["entries"][0]["id"], str(first_entry.id), ) + entry_payload = response.data["groups"][0]["days"][0]["entries"][0] + self.assertIsInstance(entry_payload["start_time_ms"], int) + self.assertIsInstance(entry_payload["end_time_ms"], int) + self.assertIsInstance(entry_payload["server_now_ms"], int) + + def test_debug_time_returns_server_clock_payload(self): + user = User.objects.create_user(mobile="09126666667", password="secret123") + self.client.force_authenticate(user=user) + + response = self.client.get("/api/time-entries/debug-time/") + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.data["server_now_ms"], int) + self.assertIn("server_now", response.data) + + def test_stop_running_time_entry_returns_server_epoch_fields(self): + user = User.objects.create_user(mobile="09126666668", password="secret123") + workspace = Workspace.objects.create(name="Core", owner=user) + entry = TimeEntry.objects.create( + workspace=workspace, + user=user, + description="Running work", + start_time=timezone.now() - timedelta(seconds=5), + ) + + self.client.force_authenticate(user=user) + response = self.client.post(f"/api/time-entries/{entry.id}/stop/", {}, format="json") + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.data["start_time_ms"], int) + self.assertIsInstance(response.data["end_time_ms"], int) + self.assertIsInstance(response.data["server_now_ms"], int) + entry.refresh_from_db() + self.assertIsNotNone(entry.duration) + self.assertGreaterEqual(entry.duration.total_seconds(), 5) def test_time_entry_update_preserves_current_deleted_tags(self): user = User.objects.create_user(mobile="09127777777", password="secret123")