Skip to content

Commit 255efb5

Browse files
Feat: Implement DELETE /tasks/{task_id}/ API to soft delete a specific task (#46)
* feat: Add API to fetch a single task by ID Implements the GET /tasks/{task_id} endpoint to retrieve details of a specific task. - Added get_by_id method to TaskRepository. - Added get_task_by_id method to TaskService with error handling for TaskNotFoundException and invalid ObjectId format. - Updated TaskView to handle requests for a single task ID, returning 404 for not found and 400 for invalid ID format. - Added new URL pattern /tasks/<str:task_id> to todo/urls.py. - Created GetTaskByIdResponse DTO for the response structure. - Created TaskNotFoundException custom exception. - Added PATH to ApiErrorSource enum for error reporting. - Added new API error messages to todo/constants/messages.py. - Added default SQLite DATABASES configuration to todo_project/settings/base.py to ensure Django's test runner operates correctly, resolving teardown errors. - Added comprehensive unit tests for the new repository method, service method, and view logic. - Added integration tests for the new endpoint, covering success (200), not found (404), and invalid ID format (400) scenarios. * refactor: modified the class to include an __init__ method that sets a default descriptive error message, while still allowing a custom message to be passed when raising the exception * refactor(tests): address review feedback on task detail integration tests . - refactor to use existing fixture instead of local mock data, improving test data consistency. * refactor: address review feedback on exceptions and service logic - Update to use a predefined constant () for its default message, improving consistency with message management. - Correct instantiation in to use instead of , aligning with Pydantic field definitions and alias usage. * refactor: Standardize Task API Views, Error Handling, and URLS - Refactored the monolithic TaskView into TaskListView (handling GET /tasks for listing and POST /tasks for creation) and TaskDetailView (handling GET /tasks/{task_id} for retrieval). - Updated URL configurations in to map to these new views, resolving previous Method Not Allowed errors and clarifying route responsibilities. - Significantly enhanced the to provide consistent JSON structures for various error types. - Ensured specific handling for (and s indicating invalid ID format), mapping them to HTTP 400 with a standardized error message (). - Corrected logic to ensure objects consistently include a for generic exceptions. - Streamlined error message usage from . - Updated to explicitly raise when is encountered from the repository. - Ensured pagination link generation in uses the correct URL name () via . - Refined exception handling within service methods to use constants from . - Consolidated error messages: removed and , relying on the primary messages ( and ). - Removed an unnecessary docstring from as per review feedback. - Updated all relevant unit and integration tests to reflect changes in view names, URL structures, error response formats, and constant usage. - Ensured tests for invalid task IDs now correctly expect HTTP 400 and the standardized error message. - Modified tests for the custom exception handler to align with its comprehensive error formatting. * refactor: fixed the grammer and also changed the constant message * fix(tasks): standardize invalid task ID error to 404 TaskNotFound - modifies to handle by raising a with the message . This results in a consistent HTTP 404 response when a task ID is malformed. - generic exception handler within has also been updated to raise . - Integration tests (): Updated to expect an HTTP 404 status and the revised error structure for invalid task ID formats. - Unit tests (): Updated to assert that is raised for invalid task ID formats. * refactor: moved the TASK_NOT_FOUND and INVALID_TASK_ID_FORMAT to ValidationErrors * refactor: moved the TASK_NOT_FOUND and INVALID_TASK_ID_FORMAT to ValidationErrors * fix: Correct HTTP status and handling for invalid task ID format * fix: Improve error handling for task ID issues and server errors * refactor: Standardize InvalidId exception handling and naming * refactor: Split TaskView, improve error handling & update tests * refactor: Split TaskView, improve error handling & update tests * fix: failing test * chore: remove unnecessary comments * feature: Implement the delete task API * test: add test for view file * test: add test for soft delete * fix:merge conflicts * fix: remove unnecessary spacing and simplify DB query logic * fix: storing the task_id in variable * refactor: remove unnecessary invalid ID exception handling * refactor: remove unnecessary invalid ID exception handling --------- Co-authored-by: Achintya-Chatterjee <[email protected]>
1 parent f69f044 commit 255efb5

File tree

8 files changed

+162
-6
lines changed

8 files changed

+162
-6
lines changed

todo/repositories/task_repository.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import datetime, timezone
22
from typing import List
33
from bson import ObjectId
4+
from pymongo import ReturnDocument
45

56
from todo.models.task import TaskModel
67
from todo.repositories.common.mongo_repository import MongoRepository
@@ -82,3 +83,23 @@ def get_by_id(cls, task_id: str) -> TaskModel | None:
8283
if task_data:
8384
return TaskModel(**task_data)
8485
return None
86+
87+
@classmethod
88+
def delete_by_id(cls, task_id: str) -> TaskModel | None:
89+
tasks_collection = cls.get_collection()
90+
91+
deleted_task_data = tasks_collection.find_one_and_update(
92+
{"_id": task_id, "isDeleted": False},
93+
{
94+
"$set": {
95+
"isDeleted": True,
96+
"updatedAt": datetime.now(timezone.utc),
97+
"updatedBy": "system",
98+
} # TODO: modify to use actual user after auth implementation,
99+
},
100+
return_document=ReturnDocument.AFTER,
101+
)
102+
103+
if deleted_task_data:
104+
return TaskModel(**deleted_task_data)
105+
return None

todo/services/task_service.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from django.urls import reverse_lazy
66
from urllib.parse import urlencode
77
from datetime import datetime, timezone
8-
98
from todo.dto.label_dto import LabelDTO
109
from todo.dto.task_dto import TaskDTO, CreateTaskDTO
1110
from todo.dto.user_dto import UserDTO
@@ -234,3 +233,10 @@ def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
234233
],
235234
)
236235
)
236+
237+
@classmethod
238+
def delete_task(cls, task_id: str) -> None:
239+
deleted_task_model = TaskRepository.delete_by_id(task_id)
240+
if deleted_task_model is None:
241+
raise TaskNotFoundException(task_id)
242+
return None

todo/tests/fixtures/task.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"isAcknowledged": True,
1717
"labels": [ObjectId("67588c1ac2195684a575840c"), ObjectId("67478036eac9d93db7f59c35")],
1818
"createdAt": "2024-11-08T10:14:35",
19+
"isDeleted": False,
1920
"updatedAt": "2024-11-08T15:14:35",
2021
"createdBy": "qMbT6M2GB65W7UHgJS4g",
2122
"updatedBy": "qMbT6M2GB65W7UHgJS4g",
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from django.urls import reverse
2+
from rest_framework import status
3+
from rest_framework.test import APITestCase
4+
from bson import ObjectId
5+
from unittest.mock import patch
6+
from todo.constants.messages import ApiErrors
7+
from todo.tests.fixtures.task import task_dtos
8+
9+
class TaskDeleteAPIIntegrationTest(APITestCase):
10+
def setUp(self):
11+
self.task_id = task_dtos[0].id
12+
13+
@patch("todo.repositories.task_repository.TaskRepository.delete_by_id")
14+
def test_delete_task_success(self, mock_delete_by_id):
15+
url = reverse("task_detail", args=[self.task_id])
16+
response = self.client.delete(url)
17+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
18+
19+
@patch("todo.repositories.task_repository.TaskRepository.delete_by_id")
20+
def test_delete_task_not_found(self, mock_delete_by_id):
21+
mock_delete_by_id.return_value = None
22+
non_existent_id = str(ObjectId())
23+
url = reverse("task_detail", args=[non_existent_id])
24+
response = self.client.delete(url)
25+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
26+
error_detail = response.data.get("errors", [{}])[0].get("detail")
27+
self.assertEqual(error_detail, ApiErrors.TASK_NOT_FOUND.format(non_existent_id))
28+
29+
def test_delete_task_invalid_id_format(self):
30+
invalid_task_id = "invalid-id"
31+
url = reverse("task_detail", args=[invalid_task_id])
32+
response = self.client.delete(url)
33+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
34+
self.assertEqual(response.data["message"], "Please enter a valid Task ID format.")
35+
self.assertIsNotNone(response.data.get("errors"))
36+
self.assertEqual(len(response.data["errors"]), 1)
37+
38+
error_obj = response.data["errors"][0]
39+
self.assertEqual(error_obj["detail"], "Please enter a valid Task ID format.")
40+
self.assertEqual(error_obj["source"]["path"], "task_id")
41+
self.assertEqual(error_obj["title"], "Validation Error")

todo/tests/unit/repositories/test_task_repository.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from unittest import TestCase
2-
from unittest.mock import patch, MagicMock
2+
from unittest.mock import ANY, patch, MagicMock
3+
from pymongo import ReturnDocument
34
from pymongo.collection import Collection
45
from bson import ObjectId, errors as bson_errors
56
from datetime import datetime, timezone
@@ -205,3 +206,45 @@ def test_create_task_handles_exception(self, mock_create):
205206

206207
self.assertIn("Failed to create task", str(context.exception))
207208
mock_create.assert_called_once_with(task)
209+
210+
211+
class TestRepositoryDeleteTaskById(TestCase):
212+
def setUp(self):
213+
self.task_id = tasks_db_data[0]["id"]
214+
self.mock_task_data = tasks_db_data[0]
215+
self.updated_task_data = self.mock_task_data.copy()
216+
self.updated_task_data.update(
217+
{
218+
"isDeleted": True,
219+
"updatedBy": "system",
220+
"updatedAt": datetime.now(timezone.utc),
221+
}
222+
)
223+
224+
@patch("todo.repositories.task_repository.TaskRepository.get_collection")
225+
def test_delete_task_success_when_isDeleted_false(self, mock_get_collection):
226+
mock_collection = MagicMock()
227+
mock_get_collection.return_value = mock_collection
228+
mock_collection.find_one_and_update.return_value = self.updated_task_data
229+
230+
result = TaskRepository.delete_by_id(self.task_id)
231+
232+
self.assertIsInstance(result, TaskModel)
233+
self.assertEqual(result.title, tasks_db_data[0]["title"])
234+
self.assertTrue(result.isDeleted)
235+
self.assertEqual(result.updatedBy, "system")
236+
self.assertIsNotNone(result.updatedAt)
237+
mock_collection.find_one_and_update.assert_called_once_with(
238+
{"_id": ObjectId(self.task_id), "isDeleted": False},
239+
{"$set": {"isDeleted": True, "updatedAt": ANY, "updatedBy": "system"}},
240+
return_document=ReturnDocument.AFTER,
241+
)
242+
243+
@patch("todo.repositories.task_repository.TaskRepository.get_collection")
244+
def test_delete_task_returns_none_when_already_deleted(self, mock_get_collection):
245+
mock_collection = MagicMock()
246+
mock_get_collection.return_value = mock_collection
247+
mock_collection.find_one_and_update.return_value = None
248+
249+
result = TaskRepository.delete_by_id(self.task_id)
250+
self.assertIsNone(result)

todo/tests/unit/services/test_task_service.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,15 @@ def test_get_task_by_id_invalid_id_format(self, mock_get_by_id_repo_method: Mock
247247

248248
self.assertEqual(str(context.exception), "Invalid ObjectId")
249249
mock_get_by_id_repo_method.assert_called_once_with(invalid_id)
250+
251+
@patch("todo.services.task_service.TaskRepository.delete_by_id")
252+
def test_delete_task_success(self, mock_delete_by_id):
253+
mock_delete_by_id.return_value = {"id": "123", "title": "Sample Task"}
254+
result = TaskService.delete_task("123")
255+
self.assertIsNone(result)
256+
257+
@patch("todo.services.task_service.TaskRepository.delete_by_id")
258+
def test_delete_task_not_found(self, mock_delete_by_id):
259+
mock_delete_by_id.return_value = None
260+
with self.assertRaises(TaskNotFoundException):
261+
TaskService.delete_task("nonexistent_id")

todo/tests/unit/views/test_task.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.conf import settings
88
from datetime import datetime, timedelta, timezone
99
from bson.objectid import ObjectId
10-
10+
from bson.errors import InvalidId as BsonInvalidId
1111
from todo.views.task import TaskListView
1212
from todo.dto.user_dto import UserDTO
1313
from todo.dto.task_dto import TaskDTO
@@ -19,7 +19,6 @@
1919
from todo.exceptions.task_exceptions import TaskNotFoundException
2020
from todo.constants.messages import ValidationErrors, ApiErrors
2121

22-
2322
class TaskViewTests(APISimpleTestCase):
2423
def setUp(self):
2524
self.client = APIClient()
@@ -300,3 +299,32 @@ def test_create_task_returns_500_on_internal_error(self, mock_create_task):
300299
self.assertIn("An unexpected error occurred", str(response.data))
301300
except Exception as e:
302301
self.assertEqual(str(e), "Database exploded")
302+
303+
304+
class TaskDeleteViewTests(APISimpleTestCase):
305+
def setUp(self):
306+
self.valid_task_id = str(ObjectId())
307+
self.url = reverse("task_detail", kwargs={"task_id": self.valid_task_id})
308+
309+
@patch("todo.services.task_service.TaskService.delete_task")
310+
def test_delete_task_returns_204_on_success(self, mock_delete_task: Mock):
311+
mock_delete_task.return_value = None
312+
response = self.client.delete(self.url)
313+
mock_delete_task.assert_called_once_with(ObjectId(self.valid_task_id))
314+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
315+
self.assertEqual(response.data, None)
316+
317+
@patch("todo.services.task_service.TaskService.delete_task")
318+
def test_delete_task_returns_404_when_not_found(self, mock_delete_task: Mock):
319+
mock_delete_task.side_effect = TaskNotFoundException(self.valid_task_id)
320+
response = self.client.delete(self.url)
321+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
322+
self.assertIn(ApiErrors.TASK_NOT_FOUND.format(self.valid_task_id), response.data["message"])
323+
324+
@patch("todo.services.task_service.TaskService.delete_task")
325+
def test_delete_task_returns_400_for_invalid_id_format(self, mock_delete_task: Mock):
326+
mock_delete_task.side_effect = BsonInvalidId()
327+
invalid_url = reverse("task_detail", kwargs={"task_id": "invalid-id"})
328+
response = self.client.delete(invalid_url)
329+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
330+
self.assertIn(ValidationErrors.INVALID_TASK_ID_FORMAT, response.data["message"])

todo/views/task.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1+
from bson import ObjectId
12
from rest_framework.views import APIView
23
from rest_framework.response import Response
34
from rest_framework import status
45
from rest_framework.request import Request
56
from django.conf import settings
6-
77
from todo.serializers.get_tasks_serializer import GetTaskQueryParamsSerializer
88
from todo.serializers.create_task_serializer import CreateTaskSerializer
99
from todo.services.task_service import TaskService
1010
from todo.dto.task_dto import CreateTaskDTO
1111
from todo.dto.responses.create_task_response import CreateTaskResponse
1212
from todo.dto.responses.get_task_by_id_response import GetTaskByIdResponse
13-
1413
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource
1514
from todo.constants.messages import ApiErrors
1615

@@ -93,3 +92,8 @@ def get(self, request: Request, task_id: str):
9392
task_dto = TaskService.get_task_by_id(task_id)
9493
response_data = GetTaskByIdResponse(data=task_dto)
9594
return Response(data=response_data.model_dump(mode="json"), status=status.HTTP_200_OK)
95+
96+
def delete(self, request: Request, task_id: str):
97+
task_id = ObjectId(task_id)
98+
TaskService.delete_task(task_id)
99+
return Response(status=status.HTTP_204_NO_CONTENT)

0 commit comments

Comments
 (0)