diff --git a/todo/serializers/create_task_serializer.py b/todo/serializers/create_task_serializer.py index a4a6e193..1990fe98 100644 --- a/todo/serializers/create_task_serializer.py +++ b/todo/serializers/create_task_serializer.py @@ -42,6 +42,11 @@ class CreateTaskSerializer(serializers.Serializer): dueAt = serializers.DateTimeField( required=False, allow_null=True, help_text="Due date and time in ISO format (UTC)" ) + team_id = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + ) def validate_title(self, value): if not value.strip(): @@ -58,6 +63,7 @@ def validate(self, data): # Compose the 'assignee' dict if assignee_id and user_type are present assignee_id = data.pop("assignee_id", None) user_type = data.pop("user_type", None) + team_id = data.pop("team_id", None) if assignee_id and user_type: if not ObjectId.is_valid(assignee_id): raise serializers.ValidationError( @@ -65,7 +71,12 @@ def validate(self, data): ) if user_type not in ["user", "team"]: raise serializers.ValidationError({"user_type": "user_type must be either 'user' or 'team'"}) - data["assignee"] = {"assignee_id": assignee_id, "user_type": user_type} + if team_id and team_id.strip(): + if not ObjectId.is_valid(team_id): + raise serializers.ValidationError({"team_id": ValidationErrors.INVALID_OBJECT_ID.format(team_id)}) + data["assignee"] = {"assignee_id": assignee_id, "user_type": user_type, "team_id": team_id} + else: + data["assignee"] = {"assignee_id": assignee_id, "user_type": user_type} due_at = data.get("dueAt") timezone_str = data.get("timezone") diff --git a/todo/services/task_assignment_service.py b/todo/services/task_assignment_service.py index 7dbbbb9a..2a4aecf3 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -71,6 +71,16 @@ def create_task_assignment(cls, dto: CreateTaskAssignmentDTO, user_id: str) -> C ) assignment = TaskAssignmentRepository.create(task_assignment) + if assignment.user_type == "user" and assignment.team_id: + AuditLogRepository.create( + AuditLogModel( + task_id=assignment.task_id, + team_id=assignment.team_id, + action="assigned_to_member", + performed_by=PyObjectId(user_id), + ) + ) + # If new assignment is to a team, log assignment if assignment.user_type == "team": AuditLogRepository.create( diff --git a/todo/services/task_service.py b/todo/services/task_service.py index 18444b20..248a7e5d 100644 --- a/todo/services/task_service.py +++ b/todo/services/task_service.py @@ -599,11 +599,16 @@ def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse: if dto.assignee: assignee_id = dto.assignee.get("assignee_id") user_type = dto.assignee.get("user_type") + team_id = dto.assignee.get("team_id") if user_type == "user": user = UserRepository.get_by_id(assignee_id) if not user: raise UserNotFoundException(assignee_id) + if team_id: + team = TeamRepository.get_by_id(team_id) + if not team: + raise ValueError(f"Team not found: {team_id}") elif user_type == "team": team = TeamRepository.get_by_id(assignee_id) if not team: @@ -631,10 +636,12 @@ def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse: # Create assignee relationship if assignee is provided team_id = None - if dto.assignee and dto.assignee.get("user_type") == "team": - team_id = dto.assignee.get("assignee_id") - if dto.assignee: + if dto.assignee.get("user_type") == "team": + team_id = dto.assignee.get("assignee_id") + elif dto.assignee.get("user_type") == "user" and "team_id" in dto.assignee: + team_id = dto.assignee.get("team_id") + assignee_dto = CreateTaskAssignmentDTO( task_id=str(created_task.id), assignee_id=dto.assignee.get("assignee_id"), diff --git a/todo/tests/unit/serializers/test_create_task_serializer.py b/todo/tests/unit/serializers/test_create_task_serializer.py index d0f1941b..c51629e7 100644 --- a/todo/tests/unit/serializers/test_create_task_serializer.py +++ b/todo/tests/unit/serializers/test_create_task_serializer.py @@ -59,3 +59,26 @@ def test_serializer_rejects_invalid_user_type(self): serializer = CreateTaskSerializer(data=data) self.assertFalse(serializer.is_valid()) self.assertIn("user_type", serializer.errors) + + def test_serializer_accepts_valid_team_id(self): + data = self.valid_data.copy() + data["team_id"] = str(ObjectId()) + serializer = CreateTaskSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertIn("assignee", serializer.validated_data) + self.assertEqual(serializer.validated_data["assignee"]["team_id"], data["team_id"]) + + def test_serializer_rejects_invalid_team_id(self): + data = self.valid_data.copy() + data["team_id"] = "invalid_team_id" + serializer = CreateTaskSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("team_id", serializer.errors) + + def test_serializer_handles_empty_team_id(self): + data = self.valid_data.copy() + data["team_id"] = "" + serializer = CreateTaskSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertIn("assignee", serializer.validated_data) + self.assertNotIn("team_id", serializer.validated_data["assignee"]) diff --git a/todo/tests/unit/services/test_task_service.py b/todo/tests/unit/services/test_task_service.py index dd9e5884..e15c98be 100644 --- a/todo/tests/unit/services/test_task_service.py +++ b/todo/tests/unit/services/test_task_service.py @@ -208,33 +208,170 @@ def test_get_tasks_handles_general_exception(self, mock_list: Mock): self.assertEqual(len(response.tasks), 0) self.assertIsNone(response.links) - # @patch("todo.services.task_service.TaskRepository.create") - # @patch("todo.services.task_service.TaskService.prepare_task_dto") - # def test_create_task_successfully_creates_task(self, mock_prepare_dto, mock_create): - # dto = CreateTaskDTO( - # title="Test Task", - # description="This is a test", - # priority=TaskPriority.HIGH, - # status=TaskStatus.TODO, - # assignee={"assignee_id": str(self.user_id), "user_type": "user"}, - # createdBy=str(self.user_id), - # labels=[], - # dueAt=datetime.now(timezone.utc) + timedelta(days=1), - # ) - - # mock_task_model = MagicMock(spec=TaskModel) - # mock_task_model.id = ObjectId() - # mock_create.return_value = mock_task_model - # mock_task_dto = MagicMock(spec=TaskDTO) - # mock_prepare_dto.return_value = mock_task_dto - - # result = TaskService.create_task(dto) - - # mock_create.assert_called_once() - # created_task_model_arg = mock_create.call_args[0][0] - # self.assertIsNone(created_task_model_arg.deferredDetails) - # mock_prepare_dto.assert_called_once_with(mock_task_model, str(self.user_id)) - # self.assertEqual(result.data, mock_task_dto) + @patch("todo.services.task_service.TaskRepository.create") + @patch("todo.services.task_service.TaskService.prepare_task_dto") + @patch("todo.services.task_service.TaskAssignmentService.create_task_assignment") + @patch("todo.services.task_service.UserRepository.get_by_id") + @patch("todo.services.task_service.TeamRepository.get_by_id") + def test_create_task_with_user_assignment_and_team_id( + self, mock_team_repo, mock_user_repo, mock_create_assignment, mock_prepare_dto, mock_create + ): + team_id = str(ObjectId()) + user_id = str(ObjectId()) + + dto = CreateTaskDTO( + title="Test Task", + description="This is a test", + priority=TaskPriority.HIGH, + status=TaskStatus.TODO, + assignee={"assignee_id": user_id, "user_type": "user", "team_id": team_id}, + createdBy=str(self.user_id), + labels=[], + dueAt=datetime.now(timezone.utc) + timedelta(days=1), + ) + + mock_user_repo.return_value = MagicMock() + mock_team_repo.return_value = MagicMock() + + mock_task_model = MagicMock(spec=TaskModel) + mock_task_model.id = ObjectId() + mock_task_model.createdBy = str(self.user_id) + mock_create.return_value = mock_task_model + mock_task_dto = MagicMock(spec=TaskDTO) + mock_prepare_dto.return_value = mock_task_dto + + mock_assignment_response = MagicMock() + mock_assignment_response.data.task_id = str(mock_task_model.id) + mock_assignment_response.data.assignee_id = user_id + mock_create_assignment.return_value = mock_assignment_response + + result = TaskService.create_task(dto) + + mock_create.assert_called_once() + + mock_create_assignment.assert_called_once() + assignment_call_args = mock_create_assignment.call_args[0][0] + self.assertEqual(assignment_call_args.task_id, str(mock_task_model.id)) + self.assertEqual(assignment_call_args.assignee_id, user_id) + self.assertEqual(assignment_call_args.user_type, "user") + self.assertEqual(assignment_call_args.team_id, team_id) + + mock_prepare_dto.assert_called_once_with(mock_task_model, str(self.user_id)) + self.assertEqual(result.data, mock_task_dto) + + @patch("todo.services.task_service.TaskRepository.create") + @patch("todo.services.task_service.TaskService.prepare_task_dto") + @patch("todo.services.task_service.TaskAssignmentService.create_task_assignment") + @patch("todo.services.task_service.UserRepository.get_by_id") + def test_create_task_with_user_assignment_without_team_id( + self, mock_user_repo, mock_create_assignment, mock_prepare_dto, mock_create + ): + user_id = str(ObjectId()) + + dto = CreateTaskDTO( + title="Test Task", + description="This is a test", + priority=TaskPriority.HIGH, + status=TaskStatus.TODO, + assignee={"assignee_id": user_id, "user_type": "user"}, + createdBy=str(self.user_id), + labels=[], + dueAt=datetime.now(timezone.utc) + timedelta(days=1), + ) + + mock_user_repo.return_value = MagicMock() + + mock_task_model = MagicMock(spec=TaskModel) + mock_task_model.id = ObjectId() + mock_task_model.createdBy = str(self.user_id) + mock_create.return_value = mock_task_model + mock_task_dto = MagicMock(spec=TaskDTO) + mock_prepare_dto.return_value = mock_task_dto + + TaskService.create_task(dto) + + mock_create_assignment.assert_called_once() + assignment_call_args = mock_create_assignment.call_args[0][0] + self.assertEqual(assignment_call_args.task_id, str(mock_task_model.id)) + self.assertEqual(assignment_call_args.assignee_id, user_id) + self.assertEqual(assignment_call_args.user_type, "user") + self.assertIsNone(assignment_call_args.team_id) + + @patch("todo.services.task_service.TaskRepository.create") + @patch("todo.services.task_service.TaskService.prepare_task_dto") + @patch("todo.services.task_service.TaskAssignmentService.create_task_assignment") + @patch("todo.services.task_service.UserRepository.get_by_id") + @patch("todo.services.task_service.TeamRepository.get_by_id") + def test_create_task_validates_team_exists_for_user_assignment( + self, mock_team_repo, mock_user_repo, mock_create_assignment, mock_prepare_dto, mock_create + ): + team_id = str(ObjectId()) + user_id = str(ObjectId()) + + dto = CreateTaskDTO( + title="Test Task", + description="This is a test", + priority=TaskPriority.HIGH, + status=TaskStatus.TODO, + assignee={"assignee_id": user_id, "user_type": "user", "team_id": team_id}, + createdBy=str(self.user_id), + labels=[], + dueAt=datetime.now(timezone.utc) + timedelta(days=1), + ) + + mock_user_repo.return_value = MagicMock() + mock_team_repo.return_value = None # Team not found + + with self.assertRaises(ValueError) as context: + TaskService.create_task(dto) + + self.assertIn(f"Team not found: {team_id}", str(context.exception)) + mock_team_repo.assert_called_once_with(team_id) + + @patch("todo.services.task_service.TaskRepository.create") + @patch("todo.services.task_service.TaskService.prepare_task_dto") + @patch("todo.services.task_service.TaskAssignmentService.create_task_assignment") + @patch("todo.services.task_service.UserRepository.get_by_id") + @patch("todo.services.task_service.TeamRepository.get_by_id") + def test_create_task_passes_team_id_to_assignment_service( + self, mock_team_repo, mock_user_repo, mock_create_assignment, mock_prepare_dto, mock_create + ): + team_id = str(ObjectId()) + user_id = str(ObjectId()) + + dto = CreateTaskDTO( + title="Test Task", + description="This is a test", + priority=TaskPriority.HIGH, + status=TaskStatus.TODO, + assignee={"assignee_id": user_id, "user_type": "user", "team_id": team_id}, + createdBy=str(self.user_id), + labels=[], + dueAt=datetime.now(timezone.utc) + timedelta(days=1), + ) + + mock_user_repo.return_value = MagicMock() + mock_team_repo.return_value = MagicMock() + + mock_task_model = MagicMock(spec=TaskModel) + mock_task_model.id = ObjectId() + mock_task_model.createdBy = str(self.user_id) + mock_create.return_value = mock_task_model + mock_task_dto = MagicMock(spec=TaskDTO) + mock_prepare_dto.return_value = mock_task_dto + + mock_assignment_response = MagicMock() + mock_assignment_response.data.task_id = str(mock_task_model.id) + mock_assignment_response.data.assignee_id = user_id + mock_create_assignment.return_value = mock_assignment_response + + TaskService.create_task(dto) + + mock_create_assignment.assert_called_once() + assignment_call_args = mock_create_assignment.call_args[0][0] + self.assertEqual(assignment_call_args.team_id, team_id) + self.assertEqual(assignment_call_args.assignee_id, user_id) + self.assertEqual(assignment_call_args.user_type, "user") @patch("todo.services.task_service.TaskRepository.get_by_id") @patch("todo.services.task_service.TaskService.prepare_task_dto")