Skip to content

Commit f69f044

Browse files
feat:Implements the GET /tasks/{task_id} endpoint to retrieve details of a specific task (#39)
* 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
1 parent 0d9cc02 commit f69f044

File tree

17 files changed

+470
-44
lines changed

17 files changed

+470
-44
lines changed

todo/constants/messages.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# Application Messages
22
class AppMessages:
33
TASK_CREATED = "Task created successfully"
4-
4+
5+
56
# Repository error messages
67
class RepositoryErrors:
78
TASK_CREATION_FAILED = "Failed to create task: {0}"
89
DB_INIT_FAILED = "Failed to initialize database: {0}"
910

11+
1012
# API error messages
1113
class ApiErrors:
1214
REPOSITORY_ERROR = "Repository Error"
@@ -18,6 +20,10 @@ class ApiErrors:
1820
INVALID_LABEL_IDS = "Invalid Label IDs"
1921
PAGE_NOT_FOUND = "Requested page exceeds available results"
2022
UNEXPECTED_ERROR_OCCURRED = "An unexpected error occurred"
23+
TASK_NOT_FOUND = "Task with ID {0} not found."
24+
TASK_NOT_FOUND_GENERIC = "Task not found."
25+
RESOURCE_NOT_FOUND_TITLE = "Resource Not Found"
26+
2127

2228
# Validation error messages
2329
class ValidationErrors:
@@ -27,4 +33,5 @@ class ValidationErrors:
2733
PAGE_POSITIVE = "Page must be a positive integer"
2834
LIMIT_POSITIVE = "Limit must be a positive integer"
2935
MAX_LIMIT_EXCEEDED = "Maximum limit of {0} exceeded"
30-
MISSING_LABEL_IDS = "The following label IDs do not exist: {0}"
36+
MISSING_LABEL_IDS = "The following label ID(s) do not exist: {0}."
37+
INVALID_TASK_ID_FORMAT = "Please enter a valid Task ID format."

todo/dto/responses/create_task_response.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from todo.dto.task_dto import TaskDTO
33
from todo.constants.messages import AppMessages
44

5+
56
class CreateTaskResponse(BaseModel):
67
statusCode: int = 201
78
successMessage: str = AppMessages.TASK_CREATED

todo/dto/responses/error_response.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class ApiErrorSource(Enum):
77
PARAMETER = "parameter"
88
POINTER = "pointer"
99
HEADER = "header"
10+
PATH = "path"
1011

1112

1213
class ApiErrorDetail(BaseModel):
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pydantic import BaseModel
2+
from todo.dto.task_dto import TaskDTO
3+
4+
5+
class GetTaskByIdResponse(BaseModel):
6+
data: TaskDTO
Lines changed: 115 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,128 @@
11
from typing import List
2-
from rest_framework.exceptions import ValidationError
2+
from rest_framework.exceptions import ValidationError as DRFValidationError
33
from rest_framework.response import Response
44
from rest_framework import status
5-
from rest_framework.views import exception_handler
5+
from rest_framework.views import exception_handler as drf_exception_handler
66
from rest_framework.utils.serializer_helpers import ReturnDict
7+
from django.conf import settings
8+
from bson.errors import InvalidId as BsonInvalidId
79

810
from todo.dto.responses.error_response import ApiErrorDetail, ApiErrorResponse, ApiErrorSource
9-
10-
11-
def handle_exception(exc, context):
12-
if isinstance(exc, ValidationError):
13-
return Response(
14-
ApiErrorResponse(
15-
statusCode=status.HTTP_400_BAD_REQUEST,
16-
message="Invalid request",
17-
errors=format_validation_errors(exc.detail),
18-
).model_dump(mode="json", exclude_none=True),
19-
status=status.HTTP_400_BAD_REQUEST,
20-
)
21-
return exception_handler(exc, context)
11+
from todo.constants.messages import ApiErrors, ValidationErrors
12+
from todo.exceptions.task_exceptions import TaskNotFoundException
2213

2314

2415
def format_validation_errors(errors) -> List[ApiErrorDetail]:
2516
formatted_errors = []
2617
if isinstance(errors, ReturnDict | dict):
2718
for field, messages in errors.items():
28-
if isinstance(messages, list):
29-
for message in messages:
30-
formatted_errors.append(ApiErrorDetail(detail=message, source={ApiErrorSource.PARAMETER: field}))
31-
elif isinstance(messages, dict):
32-
nested_errors = format_validation_errors(messages)
33-
formatted_errors.extend(nested_errors)
34-
else:
35-
formatted_errors.append(ApiErrorDetail(detail=messages, source={ApiErrorSource.PARAMETER: field}))
19+
details = messages if isinstance(messages, list) else [messages]
20+
for message_detail in details:
21+
if isinstance(message_detail, dict):
22+
nested_errors = format_validation_errors(message_detail)
23+
formatted_errors.extend(nested_errors)
24+
else:
25+
formatted_errors.append(
26+
ApiErrorDetail(detail=str(message_detail), source={ApiErrorSource.PARAMETER: field})
27+
)
28+
elif isinstance(errors, list):
29+
for message_detail in errors:
30+
formatted_errors.append(ApiErrorDetail(detail=str(message_detail)))
3631
return formatted_errors
32+
33+
34+
def handle_exception(exc, context):
35+
response = drf_exception_handler(exc, context)
36+
task_id = context.get("kwargs", {}).get("task_id")
37+
38+
error_list = []
39+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
40+
determined_message = ApiErrors.UNEXPECTED_ERROR_OCCURRED
41+
42+
if isinstance(exc, TaskNotFoundException):
43+
status_code = status.HTTP_404_NOT_FOUND
44+
detail_message_str = str(exc)
45+
determined_message = detail_message_str
46+
error_list.append(
47+
ApiErrorDetail(
48+
source={ApiErrorSource.PATH: "task_id"} if task_id else None,
49+
title=ApiErrors.RESOURCE_NOT_FOUND_TITLE,
50+
detail=detail_message_str,
51+
)
52+
)
53+
elif isinstance(exc, BsonInvalidId):
54+
status_code = status.HTTP_400_BAD_REQUEST
55+
determined_message = ValidationErrors.INVALID_TASK_ID_FORMAT
56+
error_list.append(
57+
ApiErrorDetail(
58+
source={ApiErrorSource.PATH: "task_id"} if task_id else None,
59+
title=ApiErrors.VALIDATION_ERROR,
60+
detail=ValidationErrors.INVALID_TASK_ID_FORMAT,
61+
)
62+
)
63+
elif (
64+
isinstance(exc, ValueError)
65+
and hasattr(exc, "args")
66+
and exc.args
67+
and (exc.args[0] == ValidationErrors.INVALID_TASK_ID_FORMAT or exc.args[0] == "Invalid ObjectId format")
68+
):
69+
status_code = status.HTTP_400_BAD_REQUEST
70+
determined_message = ValidationErrors.INVALID_TASK_ID_FORMAT
71+
error_list.append(
72+
ApiErrorDetail(
73+
source={ApiErrorSource.PATH: "task_id"} if task_id else None,
74+
title=ApiErrors.VALIDATION_ERROR,
75+
detail=ValidationErrors.INVALID_TASK_ID_FORMAT,
76+
)
77+
)
78+
elif (
79+
isinstance(exc, ValueError) and hasattr(exc, "args") and exc.args and isinstance(exc.args[0], ApiErrorResponse)
80+
):
81+
api_error_response = exc.args[0]
82+
return Response(
83+
data=api_error_response.model_dump(mode="json", exclude_none=True), status=api_error_response.statusCode
84+
)
85+
elif isinstance(exc, DRFValidationError):
86+
status_code = status.HTTP_400_BAD_REQUEST
87+
determined_message = "Invalid request"
88+
error_list = format_validation_errors(exc.detail)
89+
if not error_list and exc.detail:
90+
error_list.append(ApiErrorDetail(detail=str(exc.detail), title=ApiErrors.VALIDATION_ERROR))
91+
92+
else:
93+
if response is not None:
94+
status_code = response.status_code
95+
if isinstance(response.data, dict) and "detail" in response.data:
96+
detail_str = str(response.data["detail"])
97+
determined_message = detail_str
98+
error_list.append(ApiErrorDetail(detail=detail_str, title=detail_str))
99+
elif isinstance(response.data, list):
100+
for item_error in response.data:
101+
error_list.append(ApiErrorDetail(detail=str(item_error), title=determined_message))
102+
else:
103+
error_list.append(
104+
ApiErrorDetail(
105+
detail=str(response.data) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR,
106+
title=determined_message,
107+
)
108+
)
109+
else:
110+
error_list.append(
111+
ApiErrorDetail(
112+
detail=str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR, title=determined_message
113+
)
114+
)
115+
116+
if not error_list and not (
117+
isinstance(exc, ValueError) and hasattr(exc, "args") and exc.args and isinstance(exc.args[0], ApiErrorResponse)
118+
):
119+
default_detail_str = str(exc) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR
120+
121+
error_list.append(ApiErrorDetail(detail=default_detail_str, title=determined_message))
122+
123+
final_response_data = ApiErrorResponse(
124+
statusCode=status_code,
125+
message=determined_message,
126+
errors=error_list,
127+
)
128+
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)

todo/exceptions/task_exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from todo.constants.messages import ApiErrors
2+
3+
4+
class TaskNotFoundException(Exception):
5+
def __init__(self, task_id: str | None = None, message_template: str = ApiErrors.TASK_NOT_FOUND):
6+
if task_id:
7+
self.message = message_template.format(task_id)
8+
else:
9+
self.message = ApiErrors.TASK_NOT_FOUND_GENERIC
10+
super().__init__(self.message)

todo/repositories/task_repository.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import datetime, timezone
22
from typing import List
3+
from bson import ObjectId
34

45
from todo.models.task import TaskModel
56
from todo.repositories.common.mongo_repository import MongoRepository
@@ -73,3 +74,11 @@ def create(cls, task: TaskModel) -> TaskModel:
7374

7475
except Exception as e:
7576
raise ValueError(RepositoryErrors.TASK_CREATION_FAILED.format(str(e)))
77+
78+
@classmethod
79+
def get_by_id(cls, task_id: str) -> TaskModel | None:
80+
tasks_collection = cls.get_collection()
81+
task_data = tasks_collection.find_one({"_id": ObjectId(task_id)})
82+
if task_data:
83+
return TaskModel(**task_data)
84+
return None

todo/services/task_service.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from todo.constants.task import TaskStatus
2020
from todo.constants.messages import ApiErrors, ValidationErrors
2121
from django.conf import settings
22+
from todo.exceptions.task_exceptions import TaskNotFoundException
23+
from bson.errors import InvalidId as BsonInvalidId
2224

2325

2426
@dataclass
@@ -56,15 +58,15 @@ def get_tasks(
5658
return GetTasksResponse(
5759
tasks=[],
5860
links=None,
59-
error={"message": "Requested page exceeds available results", "code": "PAGE_NOT_FOUND"},
61+
error={"message": ApiErrors.PAGE_NOT_FOUND, "code": "PAGE_NOT_FOUND"},
6062
)
6163

6264
except ValidationError as e:
6365
return GetTasksResponse(tasks=[], links=None, error={"message": str(e), "code": "VALIDATION_ERROR"})
6466

6567
except Exception:
6668
return GetTasksResponse(
67-
tasks=[], links=None, error={"message": "An unexpected error occurred", "code": "INTERNAL_ERROR"}
69+
tasks=[], links=None, error={"message": ApiErrors.UNEXPECTED_ERROR_OCCURRED, "code": "INTERNAL_ERROR"}
6870
)
6971

7072
@classmethod
@@ -147,6 +149,16 @@ def _prepare_label_dtos(cls, label_ids: List[str]) -> List[LabelDTO]:
147149
def prepare_user_dto(cls, user_id: str) -> UserDTO:
148150
return UserDTO(id=user_id, name="SYSTEM")
149151

152+
@classmethod
153+
def get_task_by_id(cls, task_id: str) -> TaskDTO:
154+
try:
155+
task_model = TaskRepository.get_by_id(task_id)
156+
if not task_model:
157+
raise TaskNotFoundException(task_id)
158+
return cls.prepare_task_dto(task_model)
159+
except BsonInvalidId as exc:
160+
raise exc
161+
150162
@classmethod
151163
def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
152164
now = datetime.now(timezone.utc)
@@ -173,6 +185,7 @@ def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
173185
)
174186

175187
task = TaskModel(
188+
id=None,
176189
title=dto.title,
177190
description=dto.description,
178191
priority=dto.priority,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from unittest.mock import patch
2+
from rest_framework import status
3+
from rest_framework.test import APITestCase
4+
from django.urls import reverse
5+
from bson import ObjectId
6+
7+
from todo.services.task_service import TaskService
8+
from todo.constants.messages import ValidationErrors, ApiErrors
9+
from todo.dto.responses.error_response import ApiErrorSource
10+
from todo.exceptions.task_exceptions import TaskNotFoundException
11+
from todo.tests.fixtures.task import task_dtos
12+
from todo.constants.task import TaskPriority, TaskStatus
13+
14+
15+
class TaskDetailAPIIntegrationTest(APITestCase):
16+
@patch("todo.services.task_service.TaskService.get_task_by_id")
17+
def test_get_task_by_id_success(self, mock_get_task_by_id):
18+
fixture_task_dto = task_dtos[0]
19+
task_id_str = fixture_task_dto.id
20+
21+
mock_get_task_by_id.return_value = fixture_task_dto
22+
23+
url = reverse("task_detail", args=[task_id_str])
24+
response = self.client.get(url)
25+
26+
self.assertEqual(response.status_code, status.HTTP_200_OK)
27+
28+
response_data_outer = response.data
29+
response_data_inner = response_data_outer.get("data")
30+
self.assertIsNotNone(response_data_inner)
31+
32+
self.assertEqual(response_data_inner["id"], fixture_task_dto.id)
33+
self.assertEqual(response_data_inner["title"], fixture_task_dto.title)
34+
35+
self.assertEqual(response_data_inner["priority"], TaskPriority(fixture_task_dto.priority).name)
36+
self.assertEqual(response_data_inner["status"], TaskStatus(fixture_task_dto.status).value)
37+
38+
self.assertEqual(response_data_inner["displayId"], fixture_task_dto.displayId)
39+
40+
if fixture_task_dto.createdBy:
41+
self.assertEqual(response_data_inner["createdBy"]["id"], fixture_task_dto.createdBy.id)
42+
self.assertEqual(response_data_inner["createdBy"]["name"], fixture_task_dto.createdBy.name)
43+
44+
mock_get_task_by_id.assert_called_once_with(task_id_str)
45+
46+
@patch("todo.services.task_service.TaskService.get_task_by_id")
47+
def test_get_task_by_id_not_found(self, mock_get_task_by_id):
48+
non_existent_id = str(ObjectId())
49+
mock_get_task_by_id.side_effect = TaskNotFoundException()
50+
51+
url = reverse("task_detail", args=[non_existent_id])
52+
response = self.client.get(url)
53+
54+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
55+
error_detail = response.data.get("errors", [{}])[0].get("detail")
56+
self.assertEqual(error_detail, "Task not found.")
57+
mock_get_task_by_id.assert_called_once_with(non_existent_id)
58+
59+
@patch.object(TaskService, "get_task_by_id", wraps=TaskService.get_task_by_id)
60+
def test_get_task_by_id_invalid_format(self, mock_actual_get_task_by_id):
61+
invalid_task_id = "invalid-id"
62+
url = reverse("task_detail", args=[invalid_task_id])
63+
response = self.client.get(url)
64+
65+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
66+
self.assertEqual(response.data["message"], ValidationErrors.INVALID_TASK_ID_FORMAT)
67+
self.assertIsNotNone(response.data.get("errors"))
68+
self.assertEqual(len(response.data["errors"]), 1)
69+
70+
error_obj = response.data["errors"][0]
71+
self.assertEqual(error_obj["detail"], ValidationErrors.INVALID_TASK_ID_FORMAT)
72+
self.assertIn(ApiErrorSource.PATH.value, error_obj["source"])
73+
self.assertEqual(error_obj["source"][ApiErrorSource.PATH.value], "task_id")
74+
self.assertEqual(error_obj["title"], ApiErrors.VALIDATION_ERROR)
75+
76+
mock_actual_get_task_by_id.assert_called_once_with(invalid_task_id)

todo/tests/integration/test_tasks_pagination.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.conf import settings
44
from rest_framework.test import APIRequestFactory
55

6-
from todo.views.task import TaskView
6+
from todo.views.task import TaskListView
77
from todo.dto.responses.get_tasks_response import GetTasksResponse
88
from todo.tests.fixtures.task import task_dtos
99

@@ -13,7 +13,7 @@ class TaskPaginationIntegrationTest(TestCase):
1313

1414
def setUp(self):
1515
self.factory = APIRequestFactory()
16-
self.view = TaskView.as_view()
16+
self.view = TaskListView.as_view()
1717

1818
@patch("todo.services.task_service.TaskService.get_tasks")
1919
def test_pagination_settings_integration(self, mock_get_tasks):

0 commit comments

Comments
 (0)