Skip to content

Commit 3396fbd

Browse files
feat: GET v1/labels (#95)
* feat: added changes related to GET v1/labels * modified to single db query instead two separate db queries * fixes suggested by bot * lint fix * removed unnecessary code * modified to strip search in serialzier * feat: GET v1/labels tests (#96) * feat: added test related to GET v1/labels * tests for modified single db query instead two separate db queries * lint fix * remove unnecessary code * fix typo of previous code * tests modified to strip search in serialzier * replaced with constants
1 parent a70edb4 commit 3396fbd

File tree

17 files changed

+765
-5
lines changed

17 files changed

+765
-5
lines changed

todo/constants/messages.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class ValidationErrors:
5757
PAGE_POSITIVE = "Page must be a positive integer"
5858
LIMIT_POSITIVE = "Limit must be a positive integer"
5959
MAX_LIMIT_EXCEEDED = "Maximum limit of {0} exceeded"
60+
INVALID_SEARCH_QUERY_TYPE = "Search query must be a string."
6061
MISSING_LABEL_IDS = "The following label ID(s) do not exist: {0}."
6162
INVALID_TASK_ID_FORMAT = "Please enter a valid Task ID format."
6263
UNSUPPORTED_ACTION = "Unsupported action '{0}'."

todo/dto/label_dto.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66

77
class LabelDTO(BaseModel):
8+
id: str
89
name: str
910
color: str
1011
createdAt: datetime | None = None
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import List
2+
3+
from todo.dto.label_dto import LabelDTO
4+
from todo.dto.responses.paginated_response import PaginatedResponse
5+
6+
7+
class GetLabelsResponse(PaginatedResponse):
8+
labels: List[LabelDTO] = []
9+
total: int = 0
10+
page: int = 1
11+
limit: int = 10

todo/models/label.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import datetime
22
from typing import ClassVar
3+
34
from todo.models.common.document import Document
45

56

todo/repositories/label_repository.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from typing import List
2-
1+
from typing import List, Tuple
32
from bson import ObjectId
3+
import re
4+
45
from todo.models.label import LabelModel
56
from todo.repositories.common.mongo_repository import MongoRepository
67

@@ -15,3 +16,39 @@ def list_by_ids(cls, ids: List[ObjectId]) -> List[LabelModel]:
1516
labels_collection = cls.get_collection()
1617
labels_cursor = labels_collection.find({"_id": {"$in": ids}})
1718
return [LabelModel(**label) for label in labels_cursor]
19+
20+
@classmethod
21+
def get_all(cls, page, limit, search) -> Tuple[int, List[LabelModel]]:
22+
"""
23+
Get paginated list of labels with optional search on name.
24+
"""
25+
labels_collection = cls.get_collection()
26+
27+
query = {"isDeleted": {"$ne": True}}
28+
29+
if search:
30+
escaped_search = re.escape(search)
31+
query["name"] = {"$regex": escaped_search, "$options": "i"}
32+
33+
zero_indexed_page = page - 1
34+
skip = zero_indexed_page * limit
35+
36+
pipeline = [
37+
{"$match": query},
38+
{
39+
"$facet": {
40+
"total": [{"$count": "count"}],
41+
"data": [{"$sort": {"name": 1}}, {"$skip": skip}, {"$limit": limit}],
42+
}
43+
},
44+
]
45+
46+
aggregation_result = labels_collection.aggregate(pipeline)
47+
result = next(aggregation_result, {"total": [], "data": []})
48+
49+
total_docs = result.get("total", [])
50+
total_count = total_docs[0].get("count", 0) if total_docs else 0
51+
52+
labels = [LabelModel(**doc) for doc in result.get("data", [])]
53+
54+
return total_count, labels
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from rest_framework import serializers
2+
from django.conf import settings
3+
4+
from todo.constants.messages import ValidationErrors
5+
6+
7+
class GetLabelQueryParamsSerializer(serializers.Serializer):
8+
page = serializers.IntegerField(
9+
required=False,
10+
default=1,
11+
min_value=1,
12+
error_messages={
13+
"min_value": ValidationErrors.PAGE_POSITIVE,
14+
},
15+
)
16+
limit = serializers.IntegerField(
17+
required=False,
18+
default=10,
19+
min_value=1,
20+
max_value=settings.REST_FRAMEWORK["DEFAULT_PAGINATION_SETTINGS"]["MAX_PAGE_LIMIT"],
21+
error_messages={
22+
"min_value": ValidationErrors.LIMIT_POSITIVE,
23+
},
24+
)
25+
search = serializers.CharField(
26+
required=False,
27+
default="",
28+
allow_blank=True,
29+
error_messages={
30+
"invalid": ValidationErrors.INVALID_SEARCH_QUERY_TYPE,
31+
},
32+
)
33+
34+
def validate_search(self, value: str) -> str:
35+
return value.strip() if value else ""

todo/services/label_service.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from dataclasses import dataclass
2+
from django.conf import settings
3+
from django.urls import reverse_lazy
4+
from urllib.parse import urlencode
5+
6+
from todo.dto.responses.paginated_response import LinksData
7+
from todo.repositories.label_repository import LabelRepository
8+
from todo.dto.responses.get_labels_response import GetLabelsResponse
9+
from todo.models.label import LabelModel
10+
from todo.dto.label_dto import LabelDTO
11+
from todo.constants.messages import ApiErrors
12+
13+
14+
@dataclass
15+
class PaginationConfig:
16+
DEFAULT_PAGE: int = 1
17+
DEFAULT_LIMIT: int = 10
18+
MAX_LIMIT: int = settings.REST_FRAMEWORK["DEFAULT_PAGINATION_SETTINGS"]["MAX_PAGE_LIMIT"]
19+
SEARCH: str = ""
20+
21+
22+
class LabelService:
23+
@classmethod
24+
def get_labels(
25+
cls,
26+
page: int = PaginationConfig.DEFAULT_PAGE,
27+
limit: int = PaginationConfig.DEFAULT_LIMIT,
28+
search=PaginationConfig.SEARCH,
29+
) -> GetLabelsResponse:
30+
try:
31+
[total_count, labels] = LabelRepository.get_all(page, limit, search)
32+
total_pages = (total_count + limit - 1) // limit
33+
34+
if total_count > 0 and page > total_pages:
35+
return GetLabelsResponse(
36+
labels=[],
37+
limit=limit,
38+
links=None,
39+
error={"message": ApiErrors.PAGE_NOT_FOUND, "code": "PAGE_NOT_FOUND"},
40+
)
41+
if not labels:
42+
return GetLabelsResponse(
43+
labels=[],
44+
total=total_count,
45+
page=page,
46+
limit=limit,
47+
links=None,
48+
)
49+
50+
label_dtos = [cls.prepare_label_dto(label) for label in labels]
51+
52+
links = cls.prepare_pagination_links(page=page, total_pages=total_pages, limit=limit, search=search)
53+
54+
return GetLabelsResponse(labels=label_dtos, total=total_count, page=page, limit=limit, links=links)
55+
56+
except Exception:
57+
return GetLabelsResponse(
58+
labels=[],
59+
limit=limit,
60+
links=None,
61+
error={"message": ApiErrors.UNEXPECTED_ERROR_OCCURRED, "code": "INTERNAL_ERROR"},
62+
)
63+
64+
@classmethod
65+
def prepare_pagination_links(cls, page: int, total_pages: int, limit: int, search: str) -> LinksData:
66+
next_link = None
67+
prev_link = None
68+
69+
if page < total_pages:
70+
next_page = page + 1
71+
next_link = cls.build_page_url(next_page, limit, search)
72+
73+
if page > 1:
74+
prev_page = page - 1
75+
prev_link = cls.build_page_url(prev_page, limit, search)
76+
77+
return LinksData(next=next_link, prev=prev_link)
78+
79+
@classmethod
80+
def build_page_url(cls, page: int, limit: int, search: str) -> str:
81+
base_url = reverse_lazy("labels")
82+
query_params = urlencode({"page": page, "limit": limit, "search": search})
83+
return f"{base_url}?{query_params}"
84+
85+
@classmethod
86+
def prepare_label_dto(cls, label_model: LabelModel) -> LabelDTO:
87+
return LabelDTO(
88+
id=str(label_model.id),
89+
name=label_model.name,
90+
color=label_model.color,
91+
)

todo/services/task_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def _prepare_label_dtos(cls, label_ids: List[str]) -> List[LabelDTO]:
147147

148148
return [
149149
LabelDTO(
150+
id=str(label_model.id),
150151
name=label_model.name,
151152
color=label_model.color,
152153
createdAt=label_model.createdAt,

todo/tests/fixtures/task.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
status="TODO",
5151
assignee={"id": "qMbT6M2GB65W7UHgJS4g", "name": "SYSTEM"},
5252
isAcknowledged=False,
53-
labels=[{"name": "Beginner Friendly", "color": "#fa1e4e"}],
53+
labels=[{"id": "label-1", "name": "Beginner Friendly", "color": "#fa1e4e"}],
5454
isDeleted=False,
5555
startedAt="2024-11-09T15:14:35.724000",
5656
dueAt="2024-11-09T15:14:35.724000",
@@ -67,7 +67,7 @@
6767
status="TODO",
6868
assignee={"id": "qMbT6M2GB65W7UHgJS4g", "name": "SYSTEM"},
6969
isAcknowledged=True,
70-
labels=[{"name": "Beginner Friendly", "color": "#fa1e4e"}],
70+
labels=[{"id": "label-1", "name": "Beginner Friendly", "color": "#fa1e4e"}],
7171
isDeleted=False,
7272
startedAt="2024-11-09T15:14:35.724000",
7373
dueAt="2024-11-09T15:14:35.724000",
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from http import HTTPStatus
2+
from django.urls import reverse
3+
from django.conf import settings
4+
from bson import ObjectId
5+
6+
from todo.constants.messages import ValidationErrors
7+
from todo.tests.fixtures.label import label_db_data
8+
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
9+
from todo.constants.messages import ApiErrors
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._setup_auth_cookies()
17+
18+
def _setup_auth_cookies(self):
19+
user_data = {
20+
"user_id": str(ObjectId()),
21+
"google_id": "test_google_id",
22+
"email": "[email protected]",
23+
"name": "Test User",
24+
}
25+
tokens = generate_google_token_pair(user_data)
26+
self.client.cookies["ext-access"] = tokens["access_token"]
27+
self.client.cookies["ext-refresh"] = tokens["refresh_token"]
28+
29+
30+
class LabelListAPIIntegrationTest(AuthenticatedMongoTestCase):
31+
def setUp(self):
32+
super().setUp()
33+
self.db.labels.delete_many({})
34+
self.label_docs = []
35+
36+
for label in label_db_data:
37+
label_doc = label.copy()
38+
label_doc["_id"] = label_doc.pop("id") if "id" in label_doc else ObjectId()
39+
self.db.labels.insert_one(label_doc)
40+
self.label_docs.append(label_doc)
41+
42+
self.url = reverse("labels")
43+
44+
def test_get_labels_success(self):
45+
response = self.client.get(self.url)
46+
self.assertEqual(response.status_code, HTTPStatus.OK)
47+
48+
data = response.json()
49+
self.assertEqual(len(data["labels"]), len(self.label_docs))
50+
self.assertEqual(data["total"], len(self.label_docs))
51+
52+
for actual_label, expected_label in zip(data["labels"], self.label_docs):
53+
self.assertEqual(actual_label["name"], expected_label["name"])
54+
self.assertEqual(actual_label["color"], expected_label["color"])
55+
56+
def test_get_labels_with_search_match(self):
57+
keyword = self.label_docs[0]["name"][:3]
58+
response = self.client.get(self.url, {"search": keyword})
59+
self.assertEqual(response.status_code, HTTPStatus.OK)
60+
61+
data = response.json()
62+
self.assertGreater(len(data["labels"]), 0)
63+
self.assertTrue(any(keyword.lower() in label["name"].lower() for label in data["labels"]))
64+
65+
def test_get_labels_with_search_no_match(self):
66+
response = self.client.get(self.url, {"search": "no-match-keyword-xyz"})
67+
self.assertEqual(response.status_code, HTTPStatus.OK)
68+
69+
data = response.json()
70+
self.assertEqual(data["labels"], [])
71+
self.assertEqual(data["total"], 0)
72+
73+
def test_get_labels_with_invalid_pagination(self):
74+
response = self.client.get(self.url, {"page": 99999, "limit": 10})
75+
self.assertEqual(response.status_code, HTTPStatus.OK)
76+
77+
data = response.json()
78+
self.assertEqual(data["labels"], [])
79+
self.assertIsNotNone(data["error"])
80+
self.assertEqual(data["error"]["message"], ApiErrors.PAGE_NOT_FOUND)
81+
self.assertEqual(data["error"]["code"], "PAGE_NOT_FOUND")
82+
83+
def test_get_labels_uses_default_pagination(self):
84+
response = self.client.get(self.url)
85+
self.assertEqual(response.status_code, HTTPStatus.OK)
86+
87+
data = response.json()
88+
self.assertIn("page", data)
89+
self.assertIn("limit", data)
90+
self.assertEqual(data["page"], 1)
91+
self.assertEqual(data["limit"], 10)
92+
93+
def test_get_labels_invalid_limit_type_query_param(self):
94+
response = self.client.get(self.url, {"limit": "invalid"})
95+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
96+
97+
data = response.json()
98+
self.assertEqual(data["statusCode"], 400)
99+
self.assertEqual(data["errors"][0]["source"]["parameter"], "limit")
100+
self.assertIn("A valid integer is required.", data["errors"][0]["detail"])
101+
102+
def test_get_labels_invalid_label_query_param(self):
103+
response = self.client.get(self.url, {"limit": 0})
104+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
105+
106+
data = response.json()
107+
self.assertEqual(data["statusCode"], 400)
108+
self.assertEqual(data["errors"][0]["source"]["parameter"], "limit")
109+
self.assertIn(ValidationErrors.LIMIT_POSITIVE, data["errors"][0]["detail"])
110+
111+
def test_get_labels_greater_than_max_limit_query_param(self):
112+
response = self.client.get(self.url, {"limit": 1000})
113+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
114+
115+
MAX_PAGE_LIMIT = settings.REST_FRAMEWORK["DEFAULT_PAGINATION_SETTINGS"]["MAX_PAGE_LIMIT"]
116+
117+
data = response.json()
118+
self.assertEqual(data["statusCode"], 400)
119+
self.assertEqual(data["errors"][0]["source"]["parameter"], "limit")
120+
self.assertIn(f"Ensure this value is less than or equal to {MAX_PAGE_LIMIT}.", data["errors"][0]["detail"])
121+
122+
def test_get_labels_invalid_page_type_query_param(self):
123+
response = self.client.get(self.url, {"page": "invalid"})
124+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
125+
126+
data = response.json()
127+
self.assertEqual(data["statusCode"], 400)
128+
self.assertEqual(data["errors"][0]["source"]["parameter"], "page")
129+
self.assertIn("A valid integer is required.", data["errors"][0]["detail"])
130+
131+
def test_get_labels_invalid_page_query_param(self):
132+
response = self.client.get(self.url, {"page": 0})
133+
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
134+
135+
data = response.json()
136+
self.assertEqual(data["statusCode"], 400)
137+
self.assertEqual(data["errors"][0]["source"]["parameter"], "page")
138+
self.assertIn(ValidationErrors.PAGE_POSITIVE, data["errors"][0]["detail"])

0 commit comments

Comments
 (0)