fix(timezone): fix timer clock-skew
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user