diff --git a/connector_sftp/README.rst b/connector_sftp/README.rst new file mode 100755 index 00000000000..ff1f86f79f1 --- /dev/null +++ b/connector_sftp/README.rst @@ -0,0 +1,87 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg + :target: http://www.gnu.org/licenses/lgpl.html + :alt: License: LGPL-3 + +============== +SFTP Connector +============== + +This module allows you to connect & interact with remote SFTP hosts. + +This module does not provide functionality on its own, it is meant to provide +an abstract SFTP core to be utilized by other business logic. + +Installation +============ + +To install this module, you need to: + +* Install paramiko + ``pip install paramiko`` + +Configuration +============= + +SFTP Connectors are configured at the company level, and are available in the +``res.company`` form inside of the ``SFTP Connectors`` page. + +Usage +===== + +Read Remote File +---------------- + +.. code-block:: python + + # sftp is a ``connector.sftp`` singleton. + with sftp.open('path/to/remote/file') as file_handler: + data = file_handler.read() + +Write Remote File +----------------- + +.. code-block:: python + + # sftp is a ``connector.sftp`` singleton. + with sftp.open('path/to/remote/file', 'w') as file_handler: + file_handler.write('Some data') + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/149/10.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Dave Lasley + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/connector_sftp/__init__.py b/connector_sftp/__init__.py new file mode 100644 index 00000000000..6b7b00c350f --- /dev/null +++ b/connector_sftp/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import models diff --git a/connector_sftp/__manifest__.py b/connector_sftp/__manifest__.py new file mode 100644 index 00000000000..6f1f5fd3bd3 --- /dev/null +++ b/connector_sftp/__manifest__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "SFTP Connector", + "summary": "Framework for interacting with SFTP hosts", + "version": "10.0.1.0.0", + "category": "Base", + "website": "https://laslabs.com/", + "author": "LasLabs, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "base_external_system", + ], + "external_dependencies": { + "python": [ + 'paramiko', + ], + }, + 'data': [ + 'security/ir.model.access.csv', + ], + 'demo': [ + 'demo/connector_sftp_demo.xml', + ], +} diff --git a/connector_sftp/demo/connector_sftp_demo.xml b/connector_sftp/demo/connector_sftp_demo.xml new file mode 100755 index 00000000000..98610c7fe57 --- /dev/null +++ b/connector_sftp/demo/connector_sftp_demo.xml @@ -0,0 +1,71 @@ + + + + + + + + Example SFTP Connection + connector.sftp + sftp.example.com + 22 + Test + Password + + + + + diff --git a/connector_sftp/models/__init__.py b/connector_sftp/models/__init__.py new file mode 100644 index 00000000000..7b6c35249c8 --- /dev/null +++ b/connector_sftp/models/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import connector_sftp diff --git a/connector_sftp/models/connector_sftp.py b/connector_sftp/models/connector_sftp.py new file mode 100644 index 00000000000..e758653609d --- /dev/null +++ b/connector_sftp/models/connector_sftp.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging + +from contextlib import contextmanager +from io import StringIO + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +try: + import paramiko +except ImportError: + _logger.info('`paramiko` Python library is not installed') + + +class ConnectorSftp(models.Model): + _name = 'connector.sftp' + _inherit = 'external.system.adapter' + _description = 'SFTP Connector' + + @api.multi + def external_get_client(self): + """Return a usable SFTP client.""" + super(ConnectorSftp, self).external_get_client() + transport = paramiko.Transport(( + self.host, self.port, + )) + fingerprint = self.fingerprint or None + if self.private_key: + with StringIO(self.private_key) as io: + private_key = paramiko.RSAKey.from_private_key( + io, self.private_key_password or None, + ) + else: + private_key = None + transport.connect( + hostkey=fingerprint, + username=self.username, + password=self.password or None, + pkey=private_key, + ) + client = paramiko.SFTPClient.from_transport(transport) + if self.remote_path: + client.chdir(self.remote_path) + return client + + @api.multi + def external_destroy_client(self, client): + """Close the connection.""" + super(ConnectorSftp, self).external_destroy_client(client) + if client: + client.close() + + @api.multi + def external_test_connection(self): + with self.client() as client: + if not client: + raise ValidationError(_( + 'The SFTP connection was not able to be established.', + )) + super(ConnectorSftp, self).external_test_connection() + + @api.multi + def list_dir(self, path): + """Return a list containing the names of the entries in ``path`` + + The list is in arbitrary order. It does not include the special + entries ``'.'`` and ``'..'`` even if they are present in the folder. + This method is meant to mirror the ``os.listdir`` as closely as + possible. + + Params: + path (str): Path to list + + Returns: + list: of names in path + """ + with self.client() as client: + return client.listdir(path) + + @api.multi + def stat(self, path): + """Retrieve information about a file on remote system. Return value is + an obj whose attributes correspond to the structure of the stdlib + ``os.stat``, except that it may be lacking fields due to SFTP server + configuration. + + Unlike a Python stat object, the result may not be accessed as a tuple. + This is mostly due to the author’s slack factor. + + The fields supported are: ``st_mode``, ``st_size``, ``st_uid``, + `st_gid``, ``st_atime``, and ``st_mtime``. + + Params: + path (str): Filename to stat + + Returns: + paramiko.SFTPAttributes: object containing attributes about file. + """ + with self.client() as client: + return client.stat(path) + + @api.multi + @contextmanager + def open(self, file_name, mode='r', buff_size=-1): + """Open file on remote server. Mimicks python open function, and result + can be used as a context manager. + + Params: + file_name (str): File to open + mode (str); How to open file, reference Python open + buff_size (int): Desired buffering + + Returns: + paramiko.SFTPFile object: representing the open file + + Raises: + IOError: if the file cannot be opened + """ + with self.client() as client: + with client.open(file_name, mode, buff_size) as fh: + yield fh + + @api.multi + def delete(self, path): + """Remove the file at the given path. Does not work on directories. + + Params: + path (str): Path of file to delete. + + Raises: + IOError: if path refers to a directory. + """ + with self.client() as client: + return client.unlink(path) + + @api.multi + def symlink(self, source, dest): + """Create a symbolic link of the source path at destination on remote. + + Params: + source (str): Path of original file + dest (str): Path of new symlink + """ + with self.client() as client: + return client.symlink(source, dest) diff --git a/connector_sftp/security/ir.model.access.csv b/connector_sftp/security/ir.model.access.csv new file mode 100755 index 00000000000..a118ca119b7 --- /dev/null +++ b/connector_sftp/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_connector_sftp_manager,access.connector.sftp.user,model_connector_sftp,base.group_system,1,1,1,1 diff --git a/connector_sftp/static/description/icon.png b/connector_sftp/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/connector_sftp/static/description/icon.png differ diff --git a/connector_sftp/tests/__init__.py b/connector_sftp/tests/__init__.py new file mode 100644 index 00000000000..1443cbcfb60 --- /dev/null +++ b/connector_sftp/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_connector_sftp diff --git a/connector_sftp/tests/test_connector_sftp.py b/connector_sftp/tests/test_connector_sftp.py new file mode 100644 index 00000000000..2baa1336891 --- /dev/null +++ b/connector_sftp/tests/test_connector_sftp.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.tests.common import TransactionCase +import mock + + +paramiko = 'odoo.addons.connector_sftp.models.connector_sftp.paramiko' + + +class TestConnectorSftp(TransactionCase): + + def setUp(self, *args, **kwargs): + super(TestConnectorSftp, self).setUp(*args, **kwargs) + self.model_obj = self.env['connector.sftp'] + self.vals = { + 'name': 'Test', + 'host': 'example.com', + 'port': 22, + 'username': 'test', + 'password': 'pass', + 'host_key': 'hostkey', + 'private_key': 'privatekey', + 'company_id': self.env.user.company_id.id, + } + + def _new_record(self, ): + return self.model_obj.create(self.vals) + + @mock.patch(paramiko) + def test_compute_client_initializes_transport(self, mk): + rec_id = self._new_record() + rec_id._compute_client_and_transport() + mk.Transport.assert_called_once_with(( + self.vals['host'], self.vals['port'] + )) + + @mock.patch(paramiko) + def test_compute_client_connects_to_transport(self, mk): + rec_id = self._new_record() + rec_id._compute_client_and_transport() + mk.Transport().connect.assert_called_once_with( + hostkey=self.vals['host_key'], + username=self.vals['username'], + password=self.vals['password'], + pkey=self.vals['private_key'], + ) + + @mock.patch(paramiko) + def test_compute_client_connects_to_transport_with_no_key(self, mk): + del self.vals['private_key'] + rec_id = self._new_record() + rec_id._compute_client_and_transport() + mk.Transport().connect.assert_called_once_with( + hostkey=self.vals['host_key'], + username=self.vals['username'], + password=self.vals['password'], + pkey=None, + ) + + @mock.patch(paramiko) + def test_compute_client_connects_to_transport_with_no_hostkey(self, mk): + del self.vals['host_key'] + rec_id = self._new_record() + rec_id._compute_client_and_transport() + mk.Transport().connect.assert_called_once_with( + hostkey=None, + username=self.vals['username'], + password=self.vals['password'], + pkey=self.vals['private_key'], + ) + + @mock.patch(paramiko) + def test_compute_client_obeys_ignore_hostkey(self, mk): + self.vals['ignore_host_key'] = True + rec_id = self._new_record() + rec_id._compute_client_and_transport() + mk.Transport().connect.assert_called_once_with( + hostkey=None, + username=self.vals['username'], + password=self.vals['password'], + pkey=self.vals['private_key'], + ) + + @mock.patch(paramiko) + def test_compute_client_inits_sftp_client(self, mk): + rec_id = self._new_record() + rec_id._compute_client_and_transport() + mk.SFTPClient.from_transport.assert_called_once_with( + mk.Transport() + ) + + @mock.patch(paramiko) + def test_list_dir(self, _): + rec_id = self._new_record() + expect = 'Test' + rec_id.list_dir(expect) + rec_id.client.listdir.assert_called_once_with(expect) + + @mock.patch(paramiko) + def test_stat(self, _): + rec_id = self._new_record() + expect = 'Test' + rec_id.stat(expect) + rec_id.client.stat.assert_called_once_with(expect) + + @mock.patch(paramiko) + def test_open(self, _): + rec_id = self._new_record() + expect = 'Test', 'w', 1 + rec_id.open(*expect) + rec_id.client.open.assert_called_once_with(*expect) + + @mock.patch(paramiko) + def test_delete(self, _): + rec_id = self._new_record() + expect = 'Test' + rec_id.delete(expect) + rec_id.client.unlink.assert_called_once_with(expect) + + @mock.patch(paramiko) + def test_symlink(self, _): + rec_id = self._new_record() + expect = 'Test', 'Dest' + rec_id.symlink(*expect) + rec_id.client.symlink.assert_called_once_with(*expect) diff --git a/requirements.txt b/requirements.txt index 96edf81f212..0607a657f84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,8 @@ unidecode acme_tiny IPy email_validator +validate_email +paramiko pyotp pysftp fdb