Skip to content

Commit cef1be0

Browse files
Add task service for get task API (#12)
* Add task and label models * Add tests for label and task model * Remove json schema generator from PyObjectId This is not required * Add base mongo repository * Add task repository * Add label repository * Add tests for base mongo repository * Add tests for task repository * Add tests for label repository * Restructure health view and url * Add a basic task view * Add query param validator * Add api error response * Add exception handler for validation errors * Add tests for exception handler * Add tests for get task serializer * Add get tasks api response, task and label DTOs * Add task service * Add tests for task service * Use task service in task view * Add tests for task view * Resolve merge conflicts and move pagination settings to Django settings * fix: resolve merge conflicts and fix syntax errors - Fix merge conflicts across task views and fixtures - Fix syntax error in test_task_service.py f-strings - Update integration tests to use proper mocking - Ensure all 67 tests pass successfully * refactor: improve method signatures with named parameters - Update TaskService.get_tasks to use named parameters with default values - Refactor TaskView to pass parameters directly without intermediate variables - Update tests to use named parameters in assertions * refactor: implement Django paginator and improve error handling - Replace custom pagination logic with Django's Paginator class - Source pagination configuration values from Django settings - Break down TaskService methods for better separation of concerns - Add comprehensive error handling for validation and pagination - Improve code organization and documentation - Fix lint issues with unused exception variables - Remove unused imports * chore: remove comments from task service * refactor: implement proper error handling and improve code quality - Add error information to all exception responses - Make createdBy a required field in DTOs - Rename _build_page_url to build_page_url (made public) - Remove unused exception variables - Maintain 100% test coverage --------- Co-authored-by: Achintya-Chatterjee <[email protected]>
1 parent 63d1e8e commit cef1be0

File tree

17 files changed

+579
-36
lines changed

17 files changed

+579
-36
lines changed

todo/dto/label_dto.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from datetime import datetime
2+
from pydantic import BaseModel
3+
4+
from todo.dto.user_dto import UserDTO
5+
6+
7+
class LabelDTO(BaseModel):
8+
name: str
9+
color: str
10+
createdAt: datetime | None = None
11+
updatedAt: datetime | None = None
12+
createdBy: UserDTO | None = None
13+
updatedBy: UserDTO | None = None
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import List
2+
3+
from todo.dto.responses.paginated_response import PaginatedResponse
4+
from todo.dto.task_dto import TaskDTO
5+
6+
7+
class GetTasksResponse(PaginatedResponse):
8+
tasks: List[TaskDTO] = []
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from pydantic import BaseModel
2+
from typing import Dict, Any, Optional
3+
4+
5+
class LinksData(BaseModel):
6+
next: str | None = None
7+
prev: str | None = None
8+
9+
10+
class PaginatedResponse(BaseModel):
11+
links: LinksData | None = None
12+
error: Optional[Dict[str, Any]] = None

todo/dto/task_dto.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from datetime import datetime
2+
from typing import List
3+
from pydantic import BaseModel
4+
5+
from todo.constants.task import TaskPriority, TaskStatus
6+
from todo.dto.label_dto import LabelDTO
7+
from todo.dto.user_dto import UserDTO
8+
9+
10+
class TaskDTO(BaseModel):
11+
id: str
12+
displayId: str
13+
title: str
14+
description: str | None = None
15+
priority: TaskPriority | None = None
16+
status: TaskStatus | None = None
17+
assignee: UserDTO | None = None
18+
isAcknowledged: bool | None = None
19+
labels: List[LabelDTO] = []
20+
startedAt: datetime | None = None
21+
dueAt: datetime | None = None
22+
createdAt: datetime
23+
updatedAt: datetime | None = None
24+
createdBy: UserDTO
25+
updatedBy: UserDTO | None = None
26+
27+
class Config:
28+
json_encoders = {TaskPriority: lambda x: x.name}

todo/dto/user_dto.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pydantic import BaseModel
2+
3+
4+
class UserDTO(BaseModel):
5+
id: str
6+
name: str

todo/repositories/task_repository.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,15 @@ def list(cls, page: int, limit: int) -> List[TaskModel]:
1616
def count(cls) -> int:
1717
tasks_collection = cls.get_collection()
1818
return tasks_collection.count_documents({})
19+
20+
@classmethod
21+
def get_all(cls) -> List[TaskModel]:
22+
"""
23+
Get all tasks from the repository
24+
25+
Returns:
26+
List[TaskModel]: List of all task models
27+
"""
28+
tasks_collection = cls.get_collection()
29+
tasks_cursor = tasks_collection.find()
30+
return [TaskModel(**task) for task in tasks_cursor]

todo/services/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Added this because without this file Django isn't able to auto detect the test files

todo/services/task_service.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
from typing import List
2+
from dataclasses import dataclass
3+
from django.core.paginator import Paginator, EmptyPage
4+
from django.core.exceptions import ValidationError
5+
from django.urls import reverse_lazy
6+
from urllib.parse import urlencode
7+
8+
from todo.dto.label_dto import LabelDTO
9+
from todo.dto.task_dto import TaskDTO
10+
from todo.dto.user_dto import UserDTO
11+
from todo.dto.responses.get_tasks_response import GetTasksResponse
12+
from todo.dto.responses.paginated_response import LinksData
13+
from todo.models.task import TaskModel
14+
from todo.repositories.task_repository import TaskRepository
15+
from todo.repositories.label_repository import LabelRepository
16+
from django.conf import settings
17+
18+
19+
@dataclass
20+
class PaginationConfig:
21+
DEFAULT_PAGE: int = 1
22+
DEFAULT_LIMIT: int = settings.REST_FRAMEWORK["DEFAULT_PAGINATION_SETTINGS"]["DEFAULT_PAGE_LIMIT"]
23+
MAX_LIMIT: int = settings.REST_FRAMEWORK["DEFAULT_PAGINATION_SETTINGS"]["MAX_PAGE_LIMIT"]
24+
25+
26+
class TaskService:
27+
@classmethod
28+
def get_tasks(
29+
cls, page: int = PaginationConfig.DEFAULT_PAGE, limit: int = PaginationConfig.DEFAULT_LIMIT
30+
) -> GetTasksResponse:
31+
try:
32+
cls._validate_pagination_params(page, limit)
33+
34+
tasks = TaskRepository.get_all()
35+
36+
if not tasks:
37+
return GetTasksResponse(tasks=[], links=None)
38+
39+
paginator = Paginator(tasks, limit)
40+
41+
try:
42+
current_page = paginator.page(page)
43+
44+
task_dtos = [cls.prepare_task_dto(task) for task in current_page.object_list]
45+
46+
links = cls._prepare_pagination_links(current_page=current_page, page=page, limit=limit)
47+
48+
return GetTasksResponse(tasks=task_dtos, links=links)
49+
50+
except EmptyPage:
51+
return GetTasksResponse(
52+
tasks=[],
53+
links=None,
54+
error={"message": "Requested page exceeds available results", "code": "PAGE_NOT_FOUND"},
55+
)
56+
57+
except ValidationError as e:
58+
return GetTasksResponse(tasks=[], links=None, error={"message": str(e), "code": "VALIDATION_ERROR"})
59+
60+
except Exception:
61+
return GetTasksResponse(
62+
tasks=[], links=None, error={"message": "An unexpected error occurred", "code": "INTERNAL_ERROR"}
63+
)
64+
65+
@classmethod
66+
def _validate_pagination_params(cls, page: int, limit: int) -> None:
67+
if page < 1:
68+
raise ValidationError("Page must be a positive integer")
69+
70+
if limit < 1:
71+
raise ValidationError("Limit must be a positive integer")
72+
73+
if limit > PaginationConfig.MAX_LIMIT:
74+
raise ValidationError(f"Maximum limit of {PaginationConfig.MAX_LIMIT} exceeded")
75+
76+
@classmethod
77+
def _prepare_pagination_links(cls, current_page, page: int, limit: int) -> LinksData:
78+
next_link = None
79+
prev_link = None
80+
81+
if current_page.has_next():
82+
next_page = current_page.next_page_number()
83+
next_link = cls.build_page_url(next_page, limit)
84+
85+
if current_page.has_previous():
86+
prev_page = current_page.previous_page_number()
87+
prev_link = cls.build_page_url(prev_page, limit)
88+
89+
return LinksData(next=next_link, prev=prev_link)
90+
91+
@classmethod
92+
def build_page_url(cls, page: int, limit: int) -> str:
93+
base_url = reverse_lazy("tasks")
94+
query_params = urlencode({"page": page, "limit": limit})
95+
return f"{base_url}?{query_params}"
96+
97+
@classmethod
98+
def prepare_task_dto(cls, task_model: TaskModel) -> TaskDTO:
99+
label_dtos = cls._prepare_label_dtos(task_model.labels) if task_model.labels else []
100+
101+
assignee = cls.prepare_user_dto(task_model.assignee) if task_model.assignee else None
102+
created_by = cls.prepare_user_dto(task_model.createdBy)
103+
updated_by = cls.prepare_user_dto(task_model.updatedBy) if task_model.updatedBy else None
104+
105+
return TaskDTO(
106+
id=str(task_model.id),
107+
displayId=task_model.displayId,
108+
title=task_model.title,
109+
description=task_model.description,
110+
assignee=assignee,
111+
isAcknowledged=task_model.isAcknowledged,
112+
labels=label_dtos,
113+
startedAt=task_model.startedAt,
114+
dueAt=task_model.dueAt,
115+
status=task_model.status,
116+
priority=task_model.priority,
117+
createdAt=task_model.createdAt,
118+
updatedAt=task_model.updatedAt,
119+
createdBy=created_by,
120+
updatedBy=updated_by,
121+
)
122+
123+
@classmethod
124+
def _prepare_label_dtos(cls, label_ids: List[str]) -> List[LabelDTO]:
125+
label_models = LabelRepository.list_by_ids(label_ids)
126+
127+
return [
128+
LabelDTO(
129+
name=label_model.name,
130+
color=label_model.color,
131+
createdAt=label_model.createdAt,
132+
updatedAt=label_model.updatedAt if hasattr(label_model, "updatedAt") else None,
133+
createdBy=cls.prepare_user_dto(label_model.createdBy),
134+
updatedBy=cls.prepare_user_dto(label_model.updatedBy)
135+
if hasattr(label_model, "updatedBy") and label_model.updatedBy
136+
else None,
137+
)
138+
for label_model in label_models
139+
]
140+
141+
@classmethod
142+
def prepare_user_dto(cls, user_id: str) -> UserDTO:
143+
return UserDTO(id=user_id, name="SYSTEM")

todo/tests/fixtures/task.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from todo.constants.task import TaskPriority
22
from todo.models.task import TaskModel
33
from todo.constants.task import TaskStatus
4+
from todo.dto.task_dto import TaskDTO
45
from bson import ObjectId
56

67
tasks_db_data = [
@@ -37,3 +38,41 @@
3738
]
3839

3940
tasks_models = [TaskModel(**data) for data in tasks_db_data]
41+
42+
43+
task_dtos = [
44+
TaskDTO(
45+
id="672f7c5b775ee9f4471ff1dd",
46+
displayId="#1",
47+
title="created rest api",
48+
priority=1,
49+
status="TODO",
50+
assignee={"id": "qMbT6M2GB65W7UHgJS4g", "name": "SYSTEM"},
51+
isAcknowledged=False,
52+
labels=[{"name": "Beginner Friendly", "color": "#fa1e4e"}],
53+
isDeleted=False,
54+
startedAt="2024-11-09T15:14:35.724000",
55+
dueAt="2024-11-09T15:14:35.724000",
56+
createdAt="2024-11-09T15:14:35.724000",
57+
updatedAt="2024-10-18T15:55:14.802000Z",
58+
createdBy={"id": "xQ1CkCncM8Novk252oAj", "name": "SYSTEM"},
59+
updatedBy={"id": "Kn5N4Z3mdvpkv0HpqUCt", "name": "SYSTEM"},
60+
),
61+
TaskDTO(
62+
id="674c726ca89aab38040cb964",
63+
displayId="#1",
64+
title="task 2",
65+
priority=1,
66+
status="TODO",
67+
assignee={"id": "qMbT6M2GB65W7UHgJS4g", "name": "SYSTEM"},
68+
isAcknowledged=True,
69+
labels=[{"name": "Beginner Friendly", "color": "#fa1e4e"}],
70+
isDeleted=False,
71+
startedAt="2024-11-09T15:14:35.724000",
72+
dueAt="2024-11-09T15:14:35.724000",
73+
createdAt="2024-11-09T15:14:35.724000",
74+
updatedAt="2024-10-18T15:55:14.802000Z",
75+
createdBy={"id": "xQ1CkCncM8Novk252oAj", "name": "SYSTEM"},
76+
updatedBy={"id": "Kn5N4Z3mdvpkv0HpqUCt", "name": "SYSTEM"},
77+
),
78+
]
Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from unittest import TestCase
2+
from unittest.mock import patch
23
from django.conf import settings
34
from rest_framework.test import APIRequestFactory
45

56
from todo.views.task import TaskView
7+
from todo.dto.responses.get_tasks_response import GetTasksResponse
8+
from todo.tests.fixtures.task import task_dtos
69

710

811
class TaskPaginationIntegrationTest(TestCase):
@@ -12,20 +15,34 @@ def setUp(self):
1215
self.factory = APIRequestFactory()
1316
self.view = TaskView.as_view()
1417

15-
def test_pagination_settings_integration(self):
18+
@patch("todo.services.task_service.TaskService.get_tasks")
19+
def test_pagination_settings_integration(self, mock_get_tasks):
1620
"""Test that the view and serializer correctly use Django settings for pagination"""
21+
mock_get_tasks.return_value = GetTasksResponse(tasks=task_dtos)
22+
1723
# Test with no query params (should use default limit)
1824
request = self.factory.get("/tasks")
1925
response = self.view(request)
20-
26+
2127
# Check serializer validation passed and returned 200 OK
2228
self.assertEqual(response.status_code, 200)
23-
29+
30+
default_limit = settings.REST_FRAMEWORK["DEFAULT_PAGINATION_SETTINGS"]["DEFAULT_PAGE_LIMIT"]
31+
mock_get_tasks.assert_called_with(page=1, limit=default_limit)
32+
33+
mock_get_tasks.reset_mock()
34+
35+
request = self.factory.get("/tasks", {"limit": "10"})
36+
response = self.view(request)
37+
38+
self.assertEqual(response.status_code, 200)
39+
mock_get_tasks.assert_called_with(page=1, limit=10)
40+
2441
# Verify API rejects values above max limit
2542
max_limit = settings.REST_FRAMEWORK["DEFAULT_PAGINATION_SETTINGS"]["MAX_PAGE_LIMIT"]
2643
request = self.factory.get("/tasks", {"limit": str(max_limit + 1)})
2744
response = self.view(request)
28-
45+
2946
# Should get a 400 error
3047
self.assertEqual(response.status_code, 400)
31-
self.assertIn(str(max_limit), str(response.data))
48+
self.assertIn(str(max_limit), str(response.data))

0 commit comments

Comments
 (0)