Skip to content

Commit a10d480

Browse files
Feat: API endpoint for partially updating a task's details using PATCH /tasks/{task_id}/ (#52)
* feat: Implement PATCH /tasks/{task_id} to update tasks - Added method to handle update requests. - Introduced for validating incoming update data, ensuring all fields are optional and is a future date. - Added method to to perform the MongoDB operation, ensuring is set. - Implemented to orchestrate the update logic: - Fetches the task or raises . - Processes validated data from the serializer. - Converts label string IDs to and validates their existence. - Converts priority and status string names to their enum values for database storage. - Sets (currently placeholder). - Calls the repository to persist changes. - Corrected an issue where enum objects (Priority, Status) were passed directly to the database layer, causing a serialization error. Now, their primitive values are stored. - Refined construction in to ensure all valid fields from the serializer are correctly prepared for update. * refactor(tasks): Standardize error handling in TaskService update - Removes redundant format validation for label ObjectIds, as this is already handled by . - For missing (but validly formatted) label IDs, now raises instead of a custom * rfactor: PATCH /tasks/{task_id} endpoint to have task title blank true * refactor: improve error handling for task updates and ID validation centralizes error handling for task update operations: - Removes specific ObjectId validation from . Invalid ID formats will now correctly raise , which is handled by the global DRF exception handler to return a 400 Bad Request. - Modifies to raise if the task to be updated is not found. This is also handled by the global exception handler, resulting in a 404 Not Found response * refactor: Enhance serializer validations and repository clarity - Implemented validation to ensure `startedAt` cannot be a future date. - Added `FUTURE_STARTED_AT` to `ValidationErrors` in `constants/messages.py` for the new validation message. - Added an explicit type check to ensure `labels` input is a list or tuple. - Modified to collect all invalid ObjectId formats within the `labels` list and report them in a single `ValidationError` for better client feedback. - Moved the "Labels must be a list or tuple..." message to `ValidationErrors.INVALID_LABELS_STRUCTURE` in `constants/messages.py` and updated the serializer to use this constant. - Deleted an unreachable `if not update_data_with_timestamp: pass` block, as the updatedAt field ensures the dictionary is never empty at that point. * Update todo/serializers/update_task_serializer.py Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Update todo/views/task.py Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * fix: gramatical errors on the constants * fix: added missing imports * fix: used global exception handler for catching errors * Refactor(TaskRepository): modify update method for input validation and ObjectId handling * refactor(serializers): align validate_dueAt error handling pattern -refactors the method in to collect validation errors in a list and raise a single with this list. * refactor(services): reduce complexity of TaskService.update_task -Refactors the method in to improve readability and reduce cognitive complexity. The core logic for processing specific field types (labels, enums) has been extracted into dedicated helper methods: and * refactor(services): reduce complexity of TaskService.update_task -Refactors the method in to improve readability and reduce cognitive complexity. The core logic for processing specific field types (labels, enums) has been extracted into dedicated helper methods: and * refactor: refactor the code to improve maintainability --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 255efb5 commit a10d480

File tree

8 files changed

+176
-2
lines changed

8 files changed

+176
-2
lines changed

todo/constants/messages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ class ValidationErrors:
3535
MAX_LIMIT_EXCEEDED = "Maximum limit of {0} exceeded"
3636
MISSING_LABEL_IDS = "The following label ID(s) do not exist: {0}."
3737
INVALID_TASK_ID_FORMAT = "Please enter a valid Task ID format."
38+
FUTURE_STARTED_AT = "The start date cannot be set in the future."
39+
INVALID_LABELS_STRUCTURE = "Labels must be provided as a list or tuple of ObjectId strings."

todo/repositories/task_repository.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,30 @@ def delete_by_id(cls, task_id: str) -> TaskModel | None:
103103
if deleted_task_data:
104104
return TaskModel(**deleted_task_data)
105105
return None
106+
107+
@classmethod
108+
def update(cls, task_id: str, update_data: dict) -> TaskModel | None:
109+
"""
110+
Updates a specific task by its ID with the given data.
111+
"""
112+
if not isinstance(update_data, dict):
113+
raise ValueError("update_data must be a dictionary.")
114+
115+
try:
116+
obj_id = ObjectId(task_id)
117+
except Exception:
118+
return None
119+
120+
update_data_with_timestamp = {**update_data, "updatedAt": datetime.now(timezone.utc)}
121+
update_data_with_timestamp.pop("_id", None)
122+
update_data_with_timestamp.pop("id", None)
123+
124+
tasks_collection = cls.get_collection()
125+
126+
updated_task_doc = tasks_collection.find_one_and_update(
127+
{"_id": obj_id}, {"$set": update_data_with_timestamp}, return_document=ReturnDocument.AFTER
128+
)
129+
130+
if updated_task_doc:
131+
return TaskModel(**updated_task_doc)
132+
return None
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from rest_framework import serializers
2+
from bson import ObjectId
3+
from datetime import datetime, timezone
4+
5+
from todo.constants.task import TaskPriority, TaskStatus
6+
from todo.constants.messages import ValidationErrors
7+
8+
9+
class UpdateTaskSerializer(serializers.Serializer):
10+
title = serializers.CharField(required=False, allow_blank=True, max_length=255)
11+
description = serializers.CharField(required=False, allow_blank=True, allow_null=True)
12+
priority = serializers.ChoiceField(
13+
required=False,
14+
choices=[priority.name for priority in TaskPriority],
15+
allow_null=True,
16+
)
17+
status = serializers.ChoiceField(
18+
required=False,
19+
choices=[status.name for status in TaskStatus],
20+
allow_null=True,
21+
)
22+
assignee = serializers.CharField(required=False, allow_blank=True, allow_null=True)
23+
labels = serializers.ListField(
24+
child=serializers.CharField(),
25+
required=False,
26+
allow_null=True,
27+
)
28+
dueAt = serializers.DateTimeField(required=False, allow_null=True)
29+
startedAt = serializers.DateTimeField(required=False, allow_null=True)
30+
isAcknowledged = serializers.BooleanField(required=False)
31+
32+
def validate_title(self, value):
33+
if value is not None and not value.strip():
34+
raise serializers.ValidationError(ValidationErrors.BLANK_TITLE)
35+
return value
36+
37+
def validate_labels(self, value):
38+
if value is None:
39+
return value
40+
41+
if not isinstance(value, (list, tuple)):
42+
raise serializers.ValidationError(ValidationErrors.INVALID_LABELS_STRUCTURE)
43+
44+
invalid_ids = [label_id for label_id in value if not ObjectId.is_valid(label_id)]
45+
if invalid_ids:
46+
raise serializers.ValidationError(
47+
[ValidationErrors.INVALID_OBJECT_ID.format(label_id) for label_id in invalid_ids]
48+
)
49+
50+
return value
51+
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+
63+
def validate_startedAt(self, value):
64+
if value and value > datetime.now(timezone.utc):
65+
raise serializers.ValidationError(ValidationErrors.FUTURE_STARTED_AT)
66+
return value
67+
68+
def validate_assignee(self, value):
69+
if isinstance(value, str) and not value.strip():
70+
return None
71+
return value

todo/services/task_service.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.urls import reverse_lazy
66
from urllib.parse import urlencode
77
from datetime import datetime, timezone
8+
from rest_framework.exceptions import ValidationError as DRFValidationError
89
from todo.dto.label_dto import LabelDTO
910
from todo.dto.task_dto import TaskDTO, CreateTaskDTO
1011
from todo.dto.user_dto import UserDTO
@@ -13,9 +14,10 @@
1314
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource
1415
from todo.dto.responses.paginated_response import LinksData
1516
from todo.models.task import TaskModel
17+
from todo.models.common.pyobjectid import PyObjectId
1618
from todo.repositories.task_repository import TaskRepository
1719
from todo.repositories.label_repository import LabelRepository
18-
from todo.constants.task import TaskStatus
20+
from todo.constants.task import TaskStatus, TaskPriority
1921
from todo.constants.messages import ApiErrors, ValidationErrors
2022
from django.conf import settings
2123
from todo.exceptions.task_exceptions import TaskNotFoundException
@@ -30,6 +32,8 @@ class PaginationConfig:
3032

3133

3234
class TaskService:
35+
DIRECT_ASSIGNMENT_FIELDS = {"title", "description", "assignee", "dueAt", "startedAt", "isAcknowledged"}
36+
3337
@classmethod
3438
def get_tasks(
3539
cls, page: int = PaginationConfig.DEFAULT_PAGE, limit: int = PaginationConfig.DEFAULT_LIMIT
@@ -158,6 +162,57 @@ def get_task_by_id(cls, task_id: str) -> TaskDTO:
158162
except BsonInvalidId as exc:
159163
raise exc
160164

165+
@classmethod
166+
def _process_labels_for_update(cls, raw_labels: list | None) -> list[PyObjectId]:
167+
if raw_labels is None:
168+
return []
169+
170+
label_object_ids = [PyObjectId(label_id_str) for label_id_str in raw_labels]
171+
172+
if label_object_ids:
173+
existing_labels = LabelRepository.list_by_ids(label_object_ids)
174+
if len(existing_labels) != len(label_object_ids):
175+
found_db_ids_str = {str(label.id) for label in existing_labels}
176+
missing_ids_str = [str(py_id) for py_id in label_object_ids if str(py_id) not in found_db_ids_str]
177+
raise DRFValidationError(
178+
{"labels": [ValidationErrors.MISSING_LABEL_IDS.format(", ".join(missing_ids_str))]}
179+
)
180+
return label_object_ids
181+
182+
@classmethod
183+
def _process_enum_for_update(cls, enum_type: type, value: str | None) -> str | None:
184+
if value is None:
185+
return None
186+
return enum_type[value].value
187+
188+
@classmethod
189+
def update_task(cls, task_id: str, validated_data: dict, user_id: str = "system") -> TaskDTO:
190+
current_task = TaskRepository.get_by_id(task_id)
191+
if not current_task:
192+
raise TaskNotFoundException(task_id)
193+
194+
update_payload = {}
195+
enum_fields = {"priority": TaskPriority, "status": TaskStatus}
196+
197+
for field, value in validated_data.items():
198+
if field == "labels":
199+
update_payload[field] = cls._process_labels_for_update(value)
200+
elif field in enum_fields:
201+
update_payload[field] = cls._process_enum_for_update(enum_fields[field], value)
202+
elif field in cls.DIRECT_ASSIGNMENT_FIELDS:
203+
update_payload[field] = value
204+
205+
if not update_payload:
206+
return cls.prepare_task_dto(current_task)
207+
208+
update_payload["updatedBy"] = user_id
209+
updated_task = TaskRepository.update(task_id, update_payload)
210+
211+
if not updated_task:
212+
raise TaskNotFoundException(task_id)
213+
214+
return cls.prepare_task_dto(updated_task)
215+
161216
@classmethod
162217
def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
163218
now = datetime.now(timezone.utc)

todo/tests/integration/test_tasks_delete.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from todo.constants.messages import ApiErrors
77
from todo.tests.fixtures.task import task_dtos
88

9+
910
class TaskDeleteAPIIntegrationTest(APITestCase):
1011
def setUp(self):
1112
self.task_id = task_dtos[0].id

todo/tests/unit/repositories/test_task_repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,4 +247,4 @@ def test_delete_task_returns_none_when_already_deleted(self, mock_get_collection
247247
mock_collection.find_one_and_update.return_value = None
248248

249249
result = TaskRepository.delete_by_id(self.task_id)
250-
self.assertIsNone(result)
250+
self.assertIsNone(result)

todo/tests/unit/views/test_task.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from todo.exceptions.task_exceptions import TaskNotFoundException
2020
from todo.constants.messages import ValidationErrors, ApiErrors
2121

22+
2223
class TaskViewTests(APISimpleTestCase):
2324
def setUp(self):
2425
self.client = APIClient()

todo/views/task.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.conf import settings
77
from todo.serializers.get_tasks_serializer import GetTaskQueryParamsSerializer
88
from todo.serializers.create_task_serializer import CreateTaskSerializer
9+
from todo.serializers.update_task_serializer import UpdateTaskSerializer
910
from todo.services.task_service import TaskService
1011
from todo.dto.task_dto import CreateTaskDTO
1112
from todo.dto.responses.create_task_response import CreateTaskResponse
@@ -97,3 +98,19 @@ def delete(self, request: Request, task_id: str):
9798
task_id = ObjectId(task_id)
9899
TaskService.delete_task(task_id)
99100
return Response(status=status.HTTP_204_NO_CONTENT)
101+
102+
def patch(self, request: Request, task_id: str):
103+
"""
104+
Partially updates a task by its ID.
105+
106+
"""
107+
serializer = UpdateTaskSerializer(data=request.data, partial=True)
108+
serializer.is_valid(raise_exception=True)
109+
# This is a placeholder for the user ID, NEED TO IMPLEMENT THIS AFTER AUTHENTICATION
110+
user_id_placeholder = "system_patch_user"
111+
112+
updated_task_dto = TaskService.update_task(
113+
task_id=(task_id), validated_data=serializer.validated_data, user_id=user_id_placeholder
114+
)
115+
116+
return Response(data=updated_task_dto.model_dump(mode="json", exclude_none=True), status=status.HTTP_200_OK)

0 commit comments

Comments
 (0)