fix(timezone): fix timer clock-skew
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-26 12:59:43 +03:30
parent 20874b9968
commit b5ddcb76aa
3 changed files with 100 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from django.utils import timezone
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
@@ -31,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer):
tag_details = serializers.SerializerMethodField() tag_details = serializers.SerializerMethodField()
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") 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) 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): 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")] 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", "project_details",
"description", "description",
"start_time", "start_time",
"start_time_ms",
"end_time", "end_time",
"end_time_ms",
"server_now_ms",
"duration", "duration",
"tags", "tags",
"tag_details", "tag_details",

View File

@@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet):
filterset_class = TimeEntryFilter filterset_class = TimeEntryFilter
search_fields = ["description", "project__name", "project__client__name", "tags__name"] 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 @staticmethod
def _serialize_duration_ms(entry): def _serialize_duration_ms(entry):
if entry.duration is not None: if entry.duration is not None:
@@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet):
days_since_sunday = (local_dt.weekday() + 1) % 7 days_since_sunday = (local_dt.weekday() + 1) % 7
return (local_dt - timedelta(days=days_since_sunday)).date() return (local_dt - timedelta(days=days_since_sunday)).date()
def _build_grouped_entries(self, entries): def _build_grouped_entries(self, entries, *, server_now):
serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data 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} serialized_by_id = {item["id"]: item for item in serialized_entries}
weeks = [] weeks = []
weeks_by_key = {} weeks_by_key = {}
@@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
paginator = self.pagination_class() paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request, view=self) page = paginator.paginate_queryset(queryset, request, view=self)
server_now = timezone.now()
current_items_count = len(page) current_items_count = len(page)
has_more = (paginator.offset + current_items_count) < paginator.count has_more = (paginator.offset + current_items_count) < paginator.count
@@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet):
"offset": paginator.offset, "offset": paginator.offset,
"next_offset": paginator.offset + current_items_count if has_more else None, "next_offset": paginator.offset + current_items_count if has_more else None,
"has_more": has_more, "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 **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) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet):
**serializer.validated_data **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) return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
@@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet):
end_time = serializer.validated_data.get("end_time") end_time = serializer.validated_data.get("end_time")
stopped_entry = stop_time_entry(entry, end_time=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) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -40,6 +40,9 @@ class TimeEntryViewTests(APITestCase):
self.assertIsNone(entry.end_time) self.assertIsNone(entry.end_time)
self.assertGreaterEqual(entry.start_time, before) self.assertGreaterEqual(entry.start_time, before)
self.assertLessEqual(entry.start_time, after) 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): def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
user = User.objects.create_user(mobile="09126666666", password="secret123") 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.status_code, 200)
self.assertEqual(response.data["current_page_items_count"], 1) 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.assertFalse(response.data["has_more"])
self.assertEqual(len(response.data["groups"]), 1) self.assertEqual(len(response.data["groups"]), 1)
self.assertEqual(len(response.data["groups"][0]["days"]), 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"], response.data["groups"][0]["days"][0]["entries"][0]["id"],
str(first_entry.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): def test_time_entry_update_preserves_current_deleted_tags(self):
user = User.objects.create_user(mobile="09127777777", password="secret123") user = User.objects.create_user(mobile="09127777777", password="secret123")