Skip to content

Commit d95b7bf

Browse files
feat: Add PATCH endpoint to defer a task (#78)
* feat(tasks): Add PATCH endpoint to defer tasks introduces a new feature allowing users to defer a task to a future date. A task can be deferred by making a PATCH request to the task detail endpoint with the action=defer query parameter. - A new route in TaskDetailView that handles PATCH requests with ?action=defer. - DeferTaskSerializer to validate the incoming deferredTill timestamp, ensuring it is a future date. - A new defer_task method in TaskService containing the core business logic. This includes a validation rule that prevents deferring a task too close to its due date. - The TaskModel has been updated with a deferredDetails field to store information about the deferral. - A custom UnprocessableEntityException has been added to handle business rule violations, which the global exception handler now processes into a 422 HTTP response. * refactor: refactor the codes according to the bot suggestions * refactor: refactor the codes according to the bot suggestions * Update todo/constants/messages.py Co-authored-by: Anuj Chhikara <[email protected]> * refactor: robust task deferral endpoint refactor the PATCH /tasks/{id}?action=defer API with extensive error handling and improved business logic based on code reviews. - Adds a error when deferring a task. - Enforces a configurable notice period for deferrals close to the due date. - Hardens the API by validating the parameter strictly. - Refactors exception handling to be more specific and robust. - Improves test coverage for new service logic, views, and exceptions. * fix(task-service): normalise datetimes in defer_task to avoid naive/aware clash - Force deferred_till to be UTC-aware if it arrives naive. - Convert stored dueAt to UTC-aware before applying the minimum-notice rule. - Prevents TypeError: can't compare offset-naive and offset-aware datetimes, eliminating 500 responses on PATCH /v1/tasks?action=defer. - All integration tests now pass. * fix: missing imports * fix: added source path for task id to get the more appropiate error details * refactor: align defer task serializer with Python conventions * refactor: align defer task serializer with Python conventions * fix(db): Resolve timezone inconsistency in API datetime fields Previously, API responses for updated tasks returned naive datetime strings (e.g., 2025-10-21T02:35:25.433000), while responses for newly created tasks correctly returned timezone-aware strings (e.g., 2025-06-21T10:10:32.337301Z). This was caused by the PyMongo defaulting to . As a result, when a task was read from the database during an update operation, its timezone-aware BSON date was converted to a naive Python object, which was then serialized into the API response without timezone information. This commit configures the with . This ensures that all datetime objects retrieved from the database remain timezone-aware, guaranteeing consistent ISO 8601 formatting with the UTC 'Z' suffix across all API responses. * feat: added unit and integration tests for defer task endpoint (#79) * feat: added unit test for defer task endpoint * feat: added remaining unit tests for the defer task PATCH endpoint * fix: failing tests * fix: added deferredDetails on create task unit test * feat: added integration tests for the defer task endpoint and also added missing integration tests for update task endpoint * chore: remove comments from the integration tests code as it was unnecesseey * chore: remove comments from the integration tests code as it was unnecesseey * fix: imports * fix: enhance defer task tests and fix auth issues - corrected authentication failures in integration tests for task updates and deferrals. Introduced an AuthenticatedMongoTestCase to standardize JWT cookie setup, resolving multiple 401 Unauthorized errors. - updated the global exception handler for TaskStateConflictException to include the source field in the error response, ensuring consistency and fixing a KeyError in the corresponding unit test. - added new integration tests for the defer task endpoint to validate API behavior with invalid and missing deferredTill dates, improving the feature's robustness. - refactored the task update and deferral integration tests to follow the established authentication pattern from other tests, making them cleaner and more consistent. * fix: failing test --------- Co-authored-by: Anuj Chhikara <[email protected]>
1 parent 3270fb2 commit d95b7bf

File tree

17 files changed

+684
-19
lines changed

17 files changed

+684
-19
lines changed

todo/constants/messages.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,23 @@ class ApiErrors:
4040
INVALID_STATE_PARAMETER = "Invalid state parameter"
4141
TOKEN_REFRESH_FAILED = "Token refresh failed: {0}"
4242
LOGOUT_FAILED = "Logout failed: {0}"
43+
STATE_CONFLICT_TITLE = "State Conflict"
4344

4445

4546
# Validation error messages
4647
class ValidationErrors:
4748
BLANK_TITLE = "Title must not be blank."
4849
INVALID_OBJECT_ID = "{0} is not a valid ObjectId."
4950
PAST_DUE_DATE = "Due date must be in the future."
51+
PAST_DEFERRED_TILL_DATE = "deferredTill cannot be in the past."
52+
CANNOT_DEFER_TOO_CLOSE_TO_DUE_DATE = "Cannot defer task too close to the due date."
53+
CANNOT_DEFER_A_DONE_TASK = "Cannot defer a task that is already marked as done."
5054
PAGE_POSITIVE = "Page must be a positive integer"
5155
LIMIT_POSITIVE = "Limit must be a positive integer"
5256
MAX_LIMIT_EXCEEDED = "Maximum limit of {0} exceeded"
5357
MISSING_LABEL_IDS = "The following label ID(s) do not exist: {0}."
5458
INVALID_TASK_ID_FORMAT = "Please enter a valid Task ID format."
59+
UNSUPPORTED_ACTION = "Unsupported action '{0}'."
5560
FUTURE_STARTED_AT = "The start date cannot be set in the future."
5661
INVALID_LABELS_STRUCTURE = "Labels must be provided as a list or tuple of ObjectId strings."
5762
MISSING_GOOGLE_ID = "Google ID is required"

todo/constants/task.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ class TaskPriority(Enum):
1313
HIGH = 1
1414
MEDIUM = 2
1515
LOW = 3
16+
17+
18+
MINIMUM_DEFERRAL_NOTICE_DAYS = 20

todo/dto/deferred_details_dto.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from pydantic import BaseModel
2+
from datetime import datetime
3+
from todo.dto.user_dto import UserDTO
4+
5+
6+
class DeferredDetailsDTO(BaseModel):
7+
deferredAt: datetime
8+
deferredTill: datetime
9+
deferredBy: UserDTO

todo/dto/task_dto.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pydantic import BaseModel, field_validator
44

55
from todo.constants.task import TaskPriority, TaskStatus
6+
from todo.dto.deferred_details_dto import DeferredDetailsDTO
67
from todo.dto.label_dto import LabelDTO
78
from todo.dto.user_dto import UserDTO
89

@@ -19,6 +20,7 @@ class TaskDTO(BaseModel):
1920
labels: List[LabelDTO] = []
2021
startedAt: datetime | None = None
2122
dueAt: datetime | None = None
23+
deferredDetails: DeferredDetailsDTO | None = None
2224
createdAt: datetime
2325
updatedAt: datetime | None = None
2426
createdBy: UserDTO

todo/exceptions/exception_handler.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99

1010
from todo.dto.responses.error_response import ApiErrorDetail, ApiErrorResponse, ApiErrorSource
1111
from todo.constants.messages import ApiErrors, ValidationErrors, AuthErrorMessages
12-
from todo.exceptions.task_exceptions import TaskNotFoundException
12+
from todo.exceptions.task_exceptions import (
13+
TaskNotFoundException,
14+
UnprocessableEntityException,
15+
TaskStateConflictException,
16+
)
1317
from .auth_exceptions import TokenExpiredError, TokenMissingError, TokenInvalidError
1418
from .google_auth_exceptions import (
1519
GoogleAuthException,
@@ -183,6 +187,21 @@ def handle_exception(exc, context):
183187
detail=str(exc),
184188
)
185189
)
190+
elif isinstance(exc, UnprocessableEntityException):
191+
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
192+
determined_message = str(exc)
193+
error_list.append(
194+
ApiErrorDetail(source=exc.source, title=ApiErrors.VALIDATION_ERROR, detail=determined_message)
195+
)
196+
elif isinstance(exc, TaskStateConflictException):
197+
status_code = status.HTTP_409_CONFLICT
198+
error_list.append(
199+
ApiErrorDetail(
200+
source={"path": "task_id"},
201+
title=ApiErrors.STATE_CONFLICT_TITLE,
202+
detail=str(exc),
203+
)
204+
)
186205
elif isinstance(exc, BsonInvalidId):
187206
status_code = status.HTTP_400_BAD_REQUEST
188207
error_list.append(

todo/exceptions/task_exceptions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,16 @@ def __init__(self, task_id: str | None = None, message_template: str = ApiErrors
88
else:
99
self.message = ApiErrors.TASK_NOT_FOUND_GENERIC
1010
super().__init__(self.message)
11+
12+
13+
class UnprocessableEntityException(Exception):
14+
def __init__(self, message: str, source: dict | None = None):
15+
self.message = message
16+
self.source = source
17+
super().__init__(self.message)
18+
19+
20+
class TaskStateConflictException(Exception):
21+
def __init__(self, message: str):
22+
self.message = message
23+
super().__init__(self.message)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from rest_framework import serializers
2+
from datetime import datetime, timezone
3+
from todo.constants.messages import ValidationErrors
4+
5+
6+
class DeferTaskSerializer(serializers.Serializer):
7+
deferredTill = serializers.DateTimeField()
8+
9+
def validate_deferredTill(self, value):
10+
if value < datetime.now(timezone.utc):
11+
raise serializers.ValidationError(ValidationErrors.PAST_DEFERRED_TILL_DATE)
12+
return value

todo/services/task_service.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,28 @@
44
from django.core.exceptions import ValidationError
55
from django.urls import reverse_lazy
66
from urllib.parse import urlencode
7-
from datetime import datetime, timezone
7+
from datetime import datetime, timezone, timedelta
88
from rest_framework.exceptions import ValidationError as DRFValidationError
9+
from todo.dto.deferred_details_dto import DeferredDetailsDTO
910
from todo.dto.label_dto import LabelDTO
1011
from todo.dto.task_dto import TaskDTO, CreateTaskDTO
1112
from todo.dto.user_dto import UserDTO
1213
from todo.dto.responses.get_tasks_response import GetTasksResponse
1314
from todo.dto.responses.create_task_response import CreateTaskResponse
1415
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource
1516
from todo.dto.responses.paginated_response import LinksData
16-
from todo.models.task import TaskModel
17+
from todo.models.task import TaskModel, DeferredDetailsModel
1718
from todo.models.common.pyobjectid import PyObjectId
1819
from todo.repositories.task_repository import TaskRepository
1920
from todo.repositories.label_repository import LabelRepository
20-
from todo.constants.task import TaskStatus, TaskPriority
21+
from todo.constants.task import TaskStatus, TaskPriority, MINIMUM_DEFERRAL_NOTICE_DAYS
2122
from todo.constants.messages import ApiErrors, ValidationErrors
2223
from django.conf import settings
23-
from todo.exceptions.task_exceptions import TaskNotFoundException
24+
from todo.exceptions.task_exceptions import (
25+
TaskNotFoundException,
26+
UnprocessableEntityException,
27+
TaskStateConflictException,
28+
)
2429
from bson.errors import InvalidId as BsonInvalidId
2530

2631

@@ -111,6 +116,9 @@ def prepare_task_dto(cls, task_model: TaskModel) -> TaskDTO:
111116
assignee = cls.prepare_user_dto(task_model.assignee) if task_model.assignee else None
112117
created_by = cls.prepare_user_dto(task_model.createdBy)
113118
updated_by = cls.prepare_user_dto(task_model.updatedBy) if task_model.updatedBy else None
119+
deferred_details = (
120+
cls.prepare_deferred_details_dto(task_model.deferredDetails) if task_model.deferredDetails else None
121+
)
114122

115123
return TaskDTO(
116124
id=str(task_model.id),
@@ -124,6 +132,7 @@ def prepare_task_dto(cls, task_model: TaskModel) -> TaskDTO:
124132
dueAt=task_model.dueAt,
125133
status=task_model.status,
126134
priority=task_model.priority,
135+
deferredDetails=deferred_details,
127136
createdAt=task_model.createdAt,
128137
updatedAt=task_model.updatedAt,
129138
createdBy=created_by,
@@ -148,6 +157,19 @@ def _prepare_label_dtos(cls, label_ids: List[str]) -> List[LabelDTO]:
148157
for label_model in label_models
149158
]
150159

160+
@classmethod
161+
def prepare_deferred_details_dto(cls, deferred_details_model: DeferredDetailsModel) -> DeferredDetailsDTO | None:
162+
if not deferred_details_model:
163+
return None
164+
165+
deferred_by_user = cls.prepare_user_dto(deferred_details_model.deferredBy)
166+
167+
return DeferredDetailsDTO(
168+
deferredAt=deferred_details_model.deferredAt,
169+
deferredTill=deferred_details_model.deferredTill,
170+
deferredBy=deferred_by_user,
171+
)
172+
151173
@classmethod
152174
def prepare_user_dto(cls, user_id: str) -> UserDTO:
153175
return UserDTO(id=user_id, name="SYSTEM")
@@ -213,6 +235,50 @@ def update_task(cls, task_id: str, validated_data: dict, user_id: str = "system"
213235

214236
return cls.prepare_task_dto(updated_task)
215237

238+
@classmethod
239+
def defer_task(cls, task_id: str, deferred_till: datetime, user_id: str) -> TaskDTO:
240+
current_task = TaskRepository.get_by_id(task_id)
241+
if not current_task:
242+
raise TaskNotFoundException(task_id)
243+
244+
if current_task.status == TaskStatus.DONE:
245+
raise TaskStateConflictException(ValidationErrors.CANNOT_DEFER_A_DONE_TASK)
246+
247+
if deferred_till.tzinfo is None:
248+
deferred_till = deferred_till.replace(tzinfo=timezone.utc)
249+
250+
if current_task.dueAt:
251+
due_at = (
252+
current_task.dueAt.replace(tzinfo=timezone.utc)
253+
if current_task.dueAt.tzinfo is None
254+
else current_task.dueAt.astimezone(timezone.utc)
255+
)
256+
257+
defer_limit = due_at - timedelta(days=MINIMUM_DEFERRAL_NOTICE_DAYS)
258+
259+
if deferred_till > defer_limit:
260+
raise UnprocessableEntityException(
261+
ValidationErrors.CANNOT_DEFER_TOO_CLOSE_TO_DUE_DATE,
262+
source={ApiErrorSource.PARAMETER: "deferredTill"},
263+
)
264+
265+
deferred_details = DeferredDetailsModel(
266+
deferredAt=datetime.now(timezone.utc),
267+
deferredTill=deferred_till,
268+
deferredBy=user_id,
269+
)
270+
271+
update_payload = {
272+
"deferredDetails": deferred_details.model_dump(),
273+
"updatedBy": user_id,
274+
}
275+
276+
updated_task = TaskRepository.update(task_id, update_payload)
277+
if not updated_task:
278+
raise TaskNotFoundException(task_id)
279+
280+
return cls.prepare_task_dto(updated_task)
281+
216282
@classmethod
217283
def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
218284
now = datetime.now(timezone.utc)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from datetime import datetime, timedelta, timezone
2+
from http import HTTPStatus
3+
from bson import ObjectId
4+
from django.urls import reverse
5+
from rest_framework.test import APIClient
6+
from todo.constants.messages import ApiErrors, ValidationErrors
7+
from todo.constants.task import MINIMUM_DEFERRAL_NOTICE_DAYS, TaskPriority, TaskStatus
8+
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
9+
from todo.tests.fixtures.task import tasks_db_data
10+
from todo.utils.google_jwt_utils import generate_google_token_pair
11+
12+
13+
class AuthenticatedMongoTestCase(BaseMongoTestCase):
14+
def setUp(self):
15+
super().setUp()
16+
self.client = APIClient()
17+
self._setup_auth_cookies()
18+
19+
def _setup_auth_cookies(self):
20+
user_data = {
21+
"user_id": str(ObjectId()),
22+
"google_id": "test_google_id",
23+
"email": "[email protected]",
24+
"name": "Test User",
25+
}
26+
tokens = generate_google_token_pair(user_data)
27+
self.client.cookies["ext-access"] = tokens["access_token"]
28+
self.client.cookies["ext-refresh"] = tokens["refresh_token"]
29+
30+
31+
class TaskDeferAPIIntegrationTest(AuthenticatedMongoTestCase):
32+
def setUp(self):
33+
super().setUp()
34+
self.db.tasks.delete_many({})
35+
36+
def _insert_task(self, *, status: str = TaskStatus.TODO.value, due_at: datetime | None = None) -> str:
37+
task_fixture = tasks_db_data[0].copy()
38+
new_id = ObjectId()
39+
task_fixture["_id"] = new_id
40+
task_fixture.pop("id", None)
41+
task_fixture["displayId"] = "#IT-DEF"
42+
task_fixture["status"] = status
43+
task_fixture["priority"] = TaskPriority.MEDIUM.value
44+
task_fixture["createdAt"] = datetime.now(timezone.utc)
45+
if due_at:
46+
task_fixture["dueAt"] = due_at
47+
else:
48+
task_fixture.pop("dueAt", None)
49+
50+
self.db.tasks.insert_one(task_fixture)
51+
return str(new_id)
52+
53+
def test_defer_task_success(self):
54+
now = datetime.now(timezone.utc)
55+
due_at = now + timedelta(days=MINIMUM_DEFERRAL_NOTICE_DAYS + 30)
56+
task_id = self._insert_task(due_at=due_at)
57+
deferred_till = now + timedelta(days=10)
58+
59+
url = reverse("task_detail", args=[task_id]) + "?action=defer"
60+
response = self.client.patch(url, data={"deferredTill": deferred_till.isoformat()}, format="json")
61+
62+
self.assertEqual(response.status_code, HTTPStatus.OK)
63+
response_data = response.json()
64+
self.assertIn("deferredDetails", response_data)
65+
self.assertIsNotNone(response_data["deferredDetails"])
66+
raw_dt_str = response_data["deferredDetails"]["deferredTill"]
67+
68+
if raw_dt_str.endswith("Z"):
69+
raw_dt_str = raw_dt_str.replace("Z", "+00:00")
70+
71+
response_deferred_till = datetime.fromisoformat(raw_dt_str)
72+
73+
if response_deferred_till.tzinfo is None:
74+
response_deferred_till = response_deferred_till.replace(tzinfo=timezone.utc)
75+
76+
self.assertTrue(abs(response_deferred_till - deferred_till) < timedelta(seconds=1))
77+
78+
def test_defer_task_too_close_to_due_date_returns_422(self):
79+
now = datetime.now(timezone.utc)
80+
due_at = now + timedelta(days=MINIMUM_DEFERRAL_NOTICE_DAYS + 5)
81+
task_id = self._insert_task(due_at=due_at)
82+
83+
defer_limit = due_at - timedelta(days=MINIMUM_DEFERRAL_NOTICE_DAYS)
84+
deferred_till = defer_limit + timedelta(days=1)
85+
86+
url = reverse("task_detail", args=[task_id]) + "?action=defer"
87+
response = self.client.patch(url, data={"deferredTill": deferred_till.isoformat()}, format="json")
88+
89+
self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY)
90+
response_json = response.json()
91+
self.assertEqual(response_json["statusCode"], HTTPStatus.UNPROCESSABLE_ENTITY)
92+
self.assertEqual(response_json["message"], ValidationErrors.CANNOT_DEFER_TOO_CLOSE_TO_DUE_DATE)
93+
error = response_json["errors"][0]
94+
self.assertEqual(error["title"], ApiErrors.VALIDATION_ERROR)
95+
self.assertEqual(error["detail"], ValidationErrors.CANNOT_DEFER_TOO_CLOSE_TO_DUE_DATE)
96+
self.assertEqual(error["source"]["parameter"], "deferredTill")
97+
98+
def test_defer_done_task_returns_409(self):
99+
task_id = self._insert_task(status=TaskStatus.DONE.value)
100+
deferred_till = datetime.now(timezone.utc) + timedelta(days=5)
101+
102+
url = reverse("task_detail", args=[task_id]) + "?action=defer"
103+
response = self.client.patch(url, data={"deferredTill": deferred_till.isoformat()}, format="json")
104+
105+
self.assertEqual(response.status_code, HTTPStatus.CONFLICT)
106+
response_data = response.json()
107+
self.assertEqual(response_data["statusCode"], HTTPStatus.CONFLICT)
108+
self.assertEqual(response_data["message"], ValidationErrors.CANNOT_DEFER_A_DONE_TASK)
109+
error = response_data["errors"][0]
110+
self.assertEqual(error["title"], ApiErrors.STATE_CONFLICT_TITLE)
111+
self.assertEqual(error["detail"], ValidationErrors.CANNOT_DEFER_A_DONE_TASK)
112+
self.assertEqual(error["source"]["path"], "task_id")
113+
114+
def test_defer_task_with_invalid_date_format_returns_400(self):
115+
task_id = self._insert_task()
116+
url = reverse("task_detail", args=[task_id]) + "?action=defer"
117+
response = self.client.patch(url, data={"deferredTill": "invalid-date-format"}, format="json")
118+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
119+
response_data = response.json()
120+
self.assertEqual(response_data["errors"][0]["source"]["parameter"], "deferredTill")
121+
122+
def test_defer_task_with_missing_date_returns_400(self):
123+
task_id = self._insert_task()
124+
url = reverse("task_detail", args=[task_id]) + "?action=defer"
125+
response = self.client.patch(url, data={}, format="json")
126+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
127+
response_data = response.json()
128+
self.assertEqual(response_data["errors"][0]["source"]["parameter"], "deferredTill")
129+
self.assertIn("required", response_data["errors"][0]["detail"])

0 commit comments

Comments
 (0)