Skip to content

Commit 0cfedac

Browse files
feat: add POST /v1/tasks endpoint with validation and transaction support (#21)
* changes to Task Model for default values * feat: Create Tasks * Added extra validation and error handling * fixed null fields being ignored in response * feat(docker): enable MongoDB replica set with auto-init for transactions * refactor: code refactor according to coderabbit's review * fix: displayId as per existing notation and uniqueness through indexing * fix: failing tests due to required field * feat(task): standardize create task error responses using ApiErrorResponse * refactor: implement atomic counter for task displayId generation * fix lint and format issues * fix: accessing private method * chore(refactor): moved messages to constants * Tests for the Create tasks API (#24) * tests: Unit tests for create tasks functionality * fix: accessing private method * chore(refactor): moved messages to constants * chore(refactor): tests according to latest changes and format
1 parent cef1be0 commit 0cfedac

File tree

17 files changed

+703
-21
lines changed

17 files changed

+703
-21
lines changed

docker-compose.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,19 @@ services:
2121
ports:
2222
- "27017:27017"
2323
volumes:
24-
- ./mongo_data:/data/db
24+
- ./mongo_data:/data/db
25+
healthcheck:
26+
test: ["CMD", "mongosh", "--eval", "'db.runCommand({ping:1})'"]
27+
interval: 10s
28+
timeout: 5s
29+
retries: 5
30+
start_period: 15s
31+
32+
#to enable replica set, requirement for enabling transactions
33+
command: >
34+
sh -c "
35+
mongod --replSet rs0 --bind_ip_all --logpath /var/log/mongodb.log --logappend &
36+
sleep 5 &&
37+
mongosh --eval 'try { rs.initiate() } catch(e) { print(e) }' &&
38+
tail -f /var/log/mongodb.log
39+
"

todo/apps.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
from django.apps import AppConfig
2+
import logging
3+
from todo.constants.messages import RepositoryErrors
4+
import sys
5+
6+
logger = logging.getLogger(__name__)
27

38

49
class TodoConfig(AppConfig):
510
name = "todo"
11+
12+
def ready(self):
13+
"""Initialize application components when Django starts"""
14+
15+
if "test" in sys.argv:
16+
logger.info("Test mode detected - skipping database initialization")
17+
return
18+
19+
from todo_project.db.init import initialize_database
20+
21+
try:
22+
initialize_database()
23+
except Exception as e:
24+
logger.error(RepositoryErrors.DB_INIT_FAILED.format(str(e)))

todo/constants/messages.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Application Messages
2+
class AppMessages:
3+
TASK_CREATED = "Task created successfully"
4+
5+
# Repository error messages
6+
class RepositoryErrors:
7+
TASK_CREATION_FAILED = "Failed to create task: {0}"
8+
DB_INIT_FAILED = "Failed to initialize database: {0}"
9+
10+
# API error messages
11+
class ApiErrors:
12+
REPOSITORY_ERROR = "Repository Error"
13+
SERVER_ERROR = "Server Error"
14+
UNEXPECTED_ERROR = "Unexpected Error"
15+
INTERNAL_SERVER_ERROR = "Internal server error"
16+
VALIDATION_ERROR = "Validation Error"
17+
INVALID_LABELS = "Invalid Labels"
18+
INVALID_LABEL_IDS = "Invalid Label IDs"
19+
PAGE_NOT_FOUND = "Requested page exceeds available results"
20+
UNEXPECTED_ERROR_OCCURRED = "An unexpected error occurred"
21+
22+
# Validation error messages
23+
class ValidationErrors:
24+
BLANK_TITLE = "Title must not be blank."
25+
INVALID_OBJECT_ID = "{0} is not a valid ObjectId."
26+
PAST_DUE_DATE = "Due date must be in the future."
27+
PAGE_POSITIVE = "Page must be a positive integer"
28+
LIMIT_POSITIVE = "Limit must be a positive integer"
29+
MAX_LIMIT_EXCEEDED = "Maximum limit of {0} exceeded"
30+
MISSING_LABEL_IDS = "The following label IDs do not exist: {0}"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pydantic import BaseModel
2+
from todo.dto.task_dto import TaskDTO
3+
from todo.constants.messages import AppMessages
4+
5+
class CreateTaskResponse(BaseModel):
6+
statusCode: int = 201
7+
successMessage: str = AppMessages.TASK_CREATED
8+
data: TaskDTO

todo/dto/task_dto.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from datetime import datetime
22
from typing import List
3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, field_validator
44

55
from todo.constants.task import TaskPriority, TaskStatus
66
from todo.dto.label_dto import LabelDTO
@@ -26,3 +26,25 @@ class TaskDTO(BaseModel):
2626

2727
class Config:
2828
json_encoders = {TaskPriority: lambda x: x.name}
29+
30+
31+
class CreateTaskDTO(BaseModel):
32+
title: str
33+
description: str | None = None
34+
priority: TaskPriority = TaskPriority.LOW
35+
status: TaskStatus = TaskStatus.TODO
36+
assignee: str | None = None
37+
labels: List[str] = []
38+
dueAt: datetime | None = None
39+
40+
@field_validator("priority", mode="before")
41+
def parse_priority(cls, value):
42+
if isinstance(value, str):
43+
return TaskPriority[value]
44+
return value
45+
46+
@field_validator("status", mode="before")
47+
def parse_status(cls, value):
48+
if isinstance(value, str):
49+
return TaskStatus[value]
50+
return value

todo/models/task.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel, Field
1+
from pydantic import BaseModel, Field, ConfigDict
22
from typing import ClassVar, List
33
from datetime import datetime
44

@@ -24,13 +24,13 @@ class TaskModel(Document):
2424
collection_name: ClassVar[str] = "tasks"
2525

2626
id: PyObjectId | None = Field(None, alias="_id")
27-
displayId: str
27+
displayId: str | None = None
2828
title: str
2929
description: str | None = None
30-
priority: TaskPriority | None = None
31-
status: TaskStatus | None = None
30+
priority: TaskPriority | None = TaskPriority.LOW
31+
status: TaskStatus | None = TaskStatus.TODO
3232
assignee: str | None = None
33-
isAcknowledged: bool | None = None
33+
isAcknowledged: bool = False
3434
labels: List[PyObjectId] | None = []
3535
isDeleted: bool = False
3636
deferredDetails: DeferredDetailsModel | None = None
@@ -40,3 +40,5 @@ class TaskModel(Document):
4040
updatedAt: datetime | None = None
4141
createdBy: str
4242
updatedBy: str | None = None
43+
44+
model_config = ConfigDict(ser_enum="value")

todo/repositories/common/mongo_repository.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,11 @@ def get_collection(cls):
1818
if cls.collection is None:
1919
cls.collection = cls.database_manager.get_collection(cls.collection_name)
2020
return cls.collection
21+
22+
@classmethod
23+
def get_client(cls):
24+
return cls.database_manager._get_database_client()
25+
26+
@classmethod
27+
def get_database(cls):
28+
return cls.database_manager.get_database()

todo/repositories/task_repository.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from datetime import datetime, timezone
12
from typing import List
3+
24
from todo.models.task import TaskModel
35
from todo.repositories.common.mongo_repository import MongoRepository
6+
from todo.constants.messages import RepositoryErrors
47

58

69
class TaskRepository(MongoRepository):
@@ -28,3 +31,45 @@ def get_all(cls) -> List[TaskModel]:
2831
tasks_collection = cls.get_collection()
2932
tasks_cursor = tasks_collection.find()
3033
return [TaskModel(**task) for task in tasks_cursor]
34+
35+
@classmethod
36+
def create(cls, task: TaskModel) -> TaskModel:
37+
"""
38+
Creates a new task in the repository with a unique displayId, using atomic counter operations.
39+
40+
Args:
41+
task (TaskModel): Task to create
42+
43+
Returns:
44+
TaskModel: Created task with displayId
45+
"""
46+
tasks_collection = cls.get_collection()
47+
client = cls.get_client()
48+
49+
with client.start_session() as session:
50+
try:
51+
with session.start_transaction():
52+
# Atomically increment and get the next counter value
53+
db = cls.get_database()
54+
counter_result = db.counters.find_one_and_update(
55+
{"_id": "taskDisplayId"}, {"$inc": {"seq": 1}}, return_document=True, session=session
56+
)
57+
58+
if not counter_result:
59+
db.counters.insert_one({"_id": "taskDisplayId", "seq": 1}, session=session)
60+
next_number = 1
61+
else:
62+
next_number = counter_result["seq"]
63+
64+
task.displayId = f"#{next_number}"
65+
task.createdAt = datetime.now(timezone.utc)
66+
task.updatedAt = None
67+
68+
task_dict = task.model_dump(mode="json", by_alias=True, exclude_none=True)
69+
insert_result = tasks_collection.insert_one(task_dict, session=session)
70+
71+
task.id = insert_result.inserted_id
72+
return task
73+
74+
except Exception as e:
75+
raise ValueError(RepositoryErrors.TASK_CREATION_FAILED.format(str(e)))
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from rest_framework import serializers
2+
from bson import ObjectId
3+
from datetime import datetime, timezone
4+
from todo.constants.task import TaskPriority, TaskStatus
5+
from todo.constants.messages import ValidationErrors
6+
7+
8+
class CreateTaskSerializer(serializers.Serializer):
9+
title = serializers.CharField(required=True, allow_blank=False)
10+
description = serializers.CharField(required=False, allow_blank=True, allow_null=True)
11+
priority = serializers.ChoiceField(
12+
required=False,
13+
choices=[priority.name for priority in TaskPriority],
14+
default=TaskPriority.LOW.name,
15+
)
16+
status = serializers.ChoiceField(
17+
required=False,
18+
choices=[status.name for status in TaskStatus],
19+
default=TaskStatus.TODO.name,
20+
)
21+
assignee = serializers.CharField(required=False, allow_blank=True, allow_null=True)
22+
labels = serializers.ListField(
23+
child=serializers.CharField(),
24+
required=False,
25+
default=list,
26+
)
27+
dueAt = serializers.DateTimeField(required=False, allow_null=True)
28+
29+
def validate_title(self, value):
30+
if not value.strip():
31+
raise serializers.ValidationError(ValidationErrors.BLANK_TITLE)
32+
return value
33+
34+
def validate_labels(self, value):
35+
for label_id in value:
36+
if not ObjectId.is_valid(label_id):
37+
raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(label_id))
38+
return value
39+
40+
def validate_dueAt(self, value):
41+
if value is not None:
42+
now = datetime.now(timezone.utc)
43+
if value <= now:
44+
raise serializers.ValidationError(ValidationErrors.PAST_DUE_DATE)
45+
return value
46+
47+
def validate_assignee(self, value):
48+
if isinstance(value, str) and not value.strip():
49+
return None
50+
return value

todo/services/task_service.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44
from django.core.exceptions import ValidationError
55
from django.urls import reverse_lazy
66
from urllib.parse import urlencode
7+
from datetime import datetime, timezone
78

89
from todo.dto.label_dto import LabelDTO
9-
from todo.dto.task_dto import TaskDTO
10+
from todo.dto.task_dto import TaskDTO, CreateTaskDTO
1011
from todo.dto.user_dto import UserDTO
1112
from todo.dto.responses.get_tasks_response import GetTasksResponse
13+
from todo.dto.responses.create_task_response import CreateTaskResponse
14+
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource
1215
from todo.dto.responses.paginated_response import LinksData
1316
from todo.models.task import TaskModel
1417
from todo.repositories.task_repository import TaskRepository
1518
from todo.repositories.label_repository import LabelRepository
19+
from todo.constants.task import TaskStatus
20+
from todo.constants.messages import ApiErrors, ValidationErrors
1621
from django.conf import settings
1722

1823

@@ -141,3 +146,78 @@ def _prepare_label_dtos(cls, label_ids: List[str]) -> List[LabelDTO]:
141146
@classmethod
142147
def prepare_user_dto(cls, user_id: str) -> UserDTO:
143148
return UserDTO(id=user_id, name="SYSTEM")
149+
150+
@classmethod
151+
def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
152+
now = datetime.now(timezone.utc)
153+
started_at = now if dto.status == TaskStatus.IN_PROGRESS else None
154+
155+
if dto.labels:
156+
existing_labels = LabelRepository.list_by_ids(dto.labels)
157+
if len(existing_labels) != len(dto.labels):
158+
found_ids = [str(label.id) for label in existing_labels]
159+
missing_ids = [label_id for label_id in dto.labels if label_id not in found_ids]
160+
161+
raise ValueError(
162+
ApiErrorResponse(
163+
statusCode=400,
164+
message=ApiErrors.INVALID_LABELS,
165+
errors=[
166+
ApiErrorDetail(
167+
source={ApiErrorSource.PARAMETER: "labels"},
168+
title=ApiErrors.INVALID_LABEL_IDS,
169+
detail=ValidationErrors.MISSING_LABEL_IDS.format(", ".join(missing_ids)),
170+
)
171+
],
172+
)
173+
)
174+
175+
task = TaskModel(
176+
title=dto.title,
177+
description=dto.description,
178+
priority=dto.priority,
179+
status=dto.status,
180+
assignee=dto.assignee,
181+
labels=dto.labels,
182+
dueAt=dto.dueAt,
183+
startedAt=started_at,
184+
createdAt=now,
185+
isAcknowledged=False,
186+
isDeleted=False,
187+
createdBy="system", # placeholder, will be user_id when auth is in place
188+
)
189+
190+
try:
191+
created_task = TaskRepository.create(task)
192+
task_dto = cls.prepare_task_dto(created_task)
193+
return CreateTaskResponse(data=task_dto)
194+
except ValueError as e:
195+
if isinstance(e.args[0], ApiErrorResponse):
196+
raise e
197+
raise ValueError(
198+
ApiErrorResponse(
199+
statusCode=500,
200+
message=ApiErrors.REPOSITORY_ERROR,
201+
errors=[
202+
ApiErrorDetail(
203+
source={ApiErrorSource.PARAMETER: "task_repository"},
204+
title=ApiErrors.UNEXPECTED_ERROR,
205+
detail=str(e) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR,
206+
)
207+
],
208+
)
209+
)
210+
except Exception as e:
211+
raise ValueError(
212+
ApiErrorResponse(
213+
statusCode=500,
214+
message=ApiErrors.SERVER_ERROR,
215+
errors=[
216+
ApiErrorDetail(
217+
source={ApiErrorSource.PARAMETER: "server"},
218+
title=ApiErrors.UNEXPECTED_ERROR,
219+
detail=str(e) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR,
220+
)
221+
],
222+
)
223+
)

0 commit comments

Comments
 (0)