Skip to content

Commit 81b255c

Browse files
authored
fix(valid-due-date): update serializer to compare only dates in the user's timezone (#241)
* fix(valid-due-date): update serializer to compare only dates in IST timezone * fix(due-date-issue): added timezone field to validate current date using the user's time in serializer * fix(due-date): add error message for timezone - update serializer to validate timezone too - update tests * fix(due-date): updated update_task_serializer to throw error immediately if invalid timezone
1 parent f0cfd2c commit 81b255c

File tree

6 files changed

+58
-21
lines changed

6 files changed

+58
-21
lines changed

todo/constants/messages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ class ValidationErrors:
6161
BLANK_TITLE = "Title must not be blank."
6262
INVALID_OBJECT_ID = "{0} is not a valid ObjectId."
6363
PAST_DUE_DATE = "Due date must be in the future."
64+
REQUIRED_TIMEZONE = "Timezone is required if dueAt is provided."
65+
INVALID_TIMEZONE = "Invalid timezone."
6466
PAST_DEFERRED_TILL_DATE = "deferredTill cannot be in the past."
6567
CANNOT_DEFER_TOO_CLOSE_TO_DUE_DATE = "Cannot defer task too close to the due date."
6668
CANNOT_DEFER_A_DONE_TASK = "Cannot defer a task that is already marked as done."

todo/serializers/create_task_serializer.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from rest_framework import serializers
22
from bson import ObjectId
3-
from datetime import datetime, timezone
3+
from datetime import datetime
44
from todo.constants.task import TaskPriority, TaskStatus
55
from todo.constants.messages import ValidationErrors
6+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
67

78

89
class CreateTaskSerializer(serializers.Serializer):
@@ -35,6 +36,9 @@ class CreateTaskSerializer(serializers.Serializer):
3536
default=list,
3637
help_text="List of label IDs",
3738
)
39+
timezone = serializers.CharField(
40+
required=True, allow_null=False, help_text="IANA timezone string like 'Asia/Kolkata'"
41+
)
3842
dueAt = serializers.DateTimeField(
3943
required=False, allow_null=True, help_text="Due date and time in ISO format (UTC)"
4044
)
@@ -50,13 +54,6 @@ def validate_labels(self, value):
5054
raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(label_id))
5155
return value
5256

53-
def validate_dueAt(self, value):
54-
if value is not None:
55-
now = datetime.now(timezone.utc)
56-
if value <= now:
57-
raise serializers.ValidationError(ValidationErrors.PAST_DUE_DATE)
58-
return value
59-
6057
def validate(self, data):
6158
# Compose the 'assignee' dict if assignee_id and user_type are present
6259
assignee_id = data.pop("assignee_id", None)
@@ -69,4 +66,22 @@ def validate(self, data):
6966
if user_type not in ["user", "team"]:
7067
raise serializers.ValidationError({"user_type": "user_type must be either 'user' or 'team'"})
7168
data["assignee"] = {"assignee_id": assignee_id, "user_type": user_type}
69+
70+
due_at = data.get("dueAt")
71+
timezone_str = data.get("timezone")
72+
73+
if due_at:
74+
if not timezone_str:
75+
raise serializers.ValidationError({"timezone": ValidationErrors.REQUIRED_TIMEZONE})
76+
try:
77+
tz = ZoneInfo(timezone_str)
78+
except ZoneInfoNotFoundError:
79+
raise serializers.ValidationError({"timezone": ValidationErrors.INVALID_TIMEZONE})
80+
81+
now_date = datetime.now(tz).date()
82+
value_date = due_at.astimezone(tz).date()
83+
84+
if value_date < now_date:
85+
raise serializers.ValidationError({"dueAt": ValidationErrors.PAST_DUE_DATE})
86+
7287
return data

todo/serializers/update_task_serializer.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from todo.constants.task import TaskPriority, TaskStatus
66
from todo.constants.messages import ValidationErrors
7+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
78

89

910
class UpdateTaskSerializer(serializers.Serializer):
@@ -25,6 +26,9 @@ class UpdateTaskSerializer(serializers.Serializer):
2526
required=False,
2627
allow_null=True,
2728
)
29+
timezone = serializers.CharField(
30+
required=False, allow_null=True, help_text="IANA timezone string like 'Asia/Kolkata'"
31+
)
2832
dueAt = serializers.DateTimeField(required=False, allow_null=True)
2933
startedAt = serializers.DateTimeField(required=False, allow_null=True)
3034
isAcknowledged = serializers.BooleanField(required=False)
@@ -49,17 +53,6 @@ def validate_labels(self, value):
4953

5054
return value
5155

52-
def validate_dueAt(self, value):
53-
if value is None:
54-
return value
55-
errors = []
56-
now = datetime.now(timezone.utc)
57-
if value <= now:
58-
errors.append(ValidationErrors.PAST_DUE_DATE)
59-
if errors:
60-
raise serializers.ValidationError(errors)
61-
return value
62-
6356
def validate_startedAt(self, value):
6457
if value and value > datetime.now(timezone.utc):
6558
raise serializers.ValidationError(ValidationErrors.FUTURE_STARTED_AT)
@@ -85,3 +78,25 @@ def validate_assignee(self, value):
8578
raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(assignee_id))
8679

8780
return value
81+
82+
def validate(self, data):
83+
due_at = data.get("dueAt")
84+
timezone_str = data.get("timezone")
85+
errors = {}
86+
if due_at is not None:
87+
if not timezone_str:
88+
errors["timezone"] = [ValidationErrors.REQUIRED_TIMEZONE]
89+
else:
90+
try:
91+
tz = ZoneInfo(timezone_str)
92+
93+
now_date = datetime.now(tz).date()
94+
value_date = due_at.astimezone(tz).date()
95+
96+
if value_date < now_date:
97+
errors["dueAt"] = [ValidationErrors.PAST_DUE_DATE]
98+
except ZoneInfoNotFoundError:
99+
errors["timezone"] = [ValidationErrors.INVALID_TIMEZONE]
100+
if errors:
101+
raise serializers.ValidationError(errors)
102+
return data

todo/tests/unit/serializers/test_create_task_serializer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def setUp(self):
1717
"user_type": "user",
1818
"labels": [],
1919
"dueAt": (datetime.now(timezone.utc) + timedelta(days=2)).isoformat().replace("+00:00", "Z"),
20+
"timezone": "Asia/Calcutta",
2021
}
2122

2223
def test_serializer_validates_correct_data(self):

todo/tests/unit/serializers/test_update_task_serializer.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def test_valid_full_payload(self):
2424
"dueAt": self.future_date.isoformat(),
2525
"startedAt": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat(),
2626
"isAcknowledged": True,
27+
"timezone": "Asia/Calcutta",
2728
}
2829
serializer = UpdateTaskSerializer(data=data)
2930
self.assertTrue(serializer.is_valid(), serializer.errors)
@@ -54,6 +55,7 @@ def test_all_fields_can_be_null_or_empty_if_allowed(self):
5455
"labels": None,
5556
"dueAt": None,
5657
"startedAt": None,
58+
"timezone": None,
5759
}
5860
serializer = UpdateTaskSerializer(data=data, partial=True)
5961
self.assertTrue(serializer.is_valid(), serializer.errors)
@@ -101,14 +103,14 @@ def test_labels_can_be_empty_list(self):
101103
self.assertEqual(serializer.validated_data["labels"], [])
102104

103105
def test_due_at_validation_past_date(self):
104-
data = {"dueAt": self.past_date.isoformat()}
106+
data = {"dueAt": self.past_date.isoformat(), "timezone": "Asia/Calcutta"}
105107
serializer = UpdateTaskSerializer(data=data, partial=True)
106108
self.assertFalse(serializer.is_valid())
107109
self.assertIn("dueAt", serializer.errors)
108110
self.assertEqual(str(serializer.errors["dueAt"][0]), ValidationErrors.PAST_DUE_DATE)
109111

110112
def test_due_at_validation_future_date(self):
111-
data = {"dueAt": self.future_date.isoformat()}
113+
data = {"dueAt": self.future_date.isoformat(), "timezone": "Asia/Calcutta"}
112114
serializer = UpdateTaskSerializer(data=data, partial=True)
113115
self.assertTrue(serializer.is_valid(), serializer.errors)
114116
self.assertEqual(serializer.validated_data["dueAt"], datetime.fromisoformat(data["dueAt"]))

todo/tests/unit/views/test_task.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ def setUp(self):
402402
"assignee": {"assignee_id": self.user_id, "user_type": "user"},
403403
"labels": [],
404404
"dueAt": (datetime.now(timezone.utc) + timedelta(days=2)).isoformat().replace("+00:00", "Z"),
405+
"timezone": "Asia/Calcutta",
405406
}
406407

407408
@patch("todo.services.task_service.TaskService.create_task")
@@ -491,6 +492,7 @@ def test_create_task_returns_400_when_label_ids_are_not_objectids(self):
491492
def test_create_task_returns_400_when_dueAt_is_past(self):
492493
invalid_payload = self.valid_payload.copy()
493494
invalid_payload["dueAt"] = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat().replace("+00:00", "Z")
495+
invalid_payload["timezone"] = "Asia/Kolkata"
494496

495497
response = self.client.post(self.url, data=invalid_payload, format="json")
496498

0 commit comments

Comments
 (0)