diff --git a/requirements.txt b/requirements.txt index 3e534f74fb..9dbc265aa6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ boto3 fsspec>=2024.5.0 paramiko +pyftpdlib python_slugify diff --git a/storage_backend_ftp/README.rst b/storage_backend_ftp/README.rst new file mode 100644 index 0000000000..e302a4ef19 --- /dev/null +++ b/storage_backend_ftp/README.rst @@ -0,0 +1,84 @@ +=================== +Storage Backend FTP +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2ec164b46f6de7191262ca94f8faf47fabc90c98c1c406f71f803187f1ec6d9b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstorage-lightgray.png?logo=github + :target: https://github.com/OCA/storage/tree/18.0/storage_backend_ftp + :alt: OCA/storage +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/storage-18-0/storage-18-0-storage_backend_ftp + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/storage&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Add FTP as storage backend + +**Table of contents** + +.. contents:: + :local: + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Acsone SA/NV + +Contributors +------------ + +- François Honoré +- Lois Rilo +- thienvh + +Other credits +------------- + +The migration of this module from 14.0 to 18.0 was financially supported +by Camptocamp. + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/storage `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/storage_backend_ftp/__init__.py b/storage_backend_ftp/__init__.py new file mode 100644 index 0000000000..0f00a6730d --- /dev/null +++ b/storage_backend_ftp/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import components diff --git a/storage_backend_ftp/__manifest__.py b/storage_backend_ftp/__manifest__.py new file mode 100644 index 0000000000..091a2ccbcd --- /dev/null +++ b/storage_backend_ftp/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Storage Backend FTP", + "summary": "Implement FTP Storage", + "version": "18.0.1.0.0", + "category": "Storage", + "website": "https://github.com/OCA/storage", + "author": " Acsone SA/NV,Odoo Community Association (OCA)", + "license": "LGPL-3", + "external_dependencies": {"python": ["pyftpdlib"]}, + "depends": ["storage_backend"], + "data": ["views/backend_storage_view.xml"], +} diff --git a/storage_backend_ftp/components/__init__.py b/storage_backend_ftp/components/__init__.py new file mode 100644 index 0000000000..72dbc3a308 --- /dev/null +++ b/storage_backend_ftp/components/__init__.py @@ -0,0 +1 @@ +from . import ftp_adapter diff --git a/storage_backend_ftp/components/ftp_adapter.py b/storage_backend_ftp/components/ftp_adapter.py new file mode 100644 index 0000000000..17b5f3a093 --- /dev/null +++ b/storage_backend_ftp/components/ftp_adapter.py @@ -0,0 +1,171 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import errno +import io +import logging +import os +import ssl +from contextlib import contextmanager +from io import BytesIO + +from odoo.exceptions import UserError + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + +try: + import ftplib +except ImportError as err: # pragma: no cover + _logger.debug(err) + +FTP_SECURITY_TO_PROTOCOL = { + "tls": ssl.PROTOCOL_TLS, + "tlsv1": ssl.PROTOCOL_TLSv1, + "tlsv1_1": ssl.PROTOCOL_TLSv1_1, + "tlsv1_2": ssl.PROTOCOL_TLSv1_2, + "sslv2": "sslv2 has been deprecated due to security issues", + "sslv23": ssl.PROTOCOL_SSLv23, + "sslv3": "sslv3 has been deprecated due to security issues", +} + + +def ftp_mkdirs(client, path): + try: + client.mkd(path) + except OSError as e: + if e.errno == errno.ENOENT and path: + ftp_mkdirs(client, os.path.dirname(path)) + client.mkd(path) + else: + raise # pragma: no cover + + +class ImplicitFTPTLS(ftplib.FTP_TLS): + """ + FTP_TLS subclass that automatically wraps sockets in SSL + to support implicit FTPS. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._sock = None + + @property + def sock(self): + """Return the socket.""" + return self._sock + + @sock.setter + def sock(self, value): + """When modifying the socket, ensure that it is ssl wrapped.""" + if value is not None and not isinstance(value, ssl.SSLSocket): + value = self.context.wrap_socket(value) + self._sock = value + + +@contextmanager +def ftp(backend): + security = None + prot_p = False + if backend.ftp_encryption in ["ftp", "tls", "tls_explicit"]: + if backend.ftp_encryption == "ftp": + _ftp = ftplib.FTP(timeout=30) + elif backend.ftp_encryption == "tls": + _ftp = ImplicitFTPTLS() + # Due to a bug between ftplib and ssl, this part (about ssl) + # might not work! See: https://bugs.python.org/issue31727 + security = FTP_SECURITY_TO_PROTOCOL.get(backend.ftp_security, None) + prot_p = True + if isinstance(security, str): + raise UserError(security) + elif backend.ftp_encryption == "tls_explicit": + _ftp = ftplib.FTP_TLS(timeout=30) + prot_p = True + with _ftp as client: + if security: + client.ssl_version = security + client.connect(host=backend.ftp_server, port=backend.ftp_port) + client.login(backend.ftp_login, backend.ftp_password) + if prot_p: + client.prot_p() + if backend.ftp_passive: + client.set_pasv(True) + yield client + + +class FTPStorageBackendAdapter(Component): + _name = "ftp.adapter" + _inherit = "base.storage.adapter" + _usage = "ftp" + + def add(self, relative_path, data, **kwargs): + with ftp(self.collection) as client: + full_path = self._fullpath(relative_path) + dirname = os.path.dirname(full_path) + if dirname: + try: + client.cwd(dirname) + except OSError as e: + if e.errno == errno.ENOENT: + ftp_mkdirs(client, dirname) + else: + raise # pragma: no cover + with io.BytesIO(data) as tmp_file: + try: + client.storbinary("STOR " + full_path, tmp_file) + except ftplib.Error as e: + raise ValueError(repr(e)) from e + except OSError as e: + raise ValueError(repr(e)) from e + + def get(self, relative_path, **kwargs): + full_path = self._fullpath(relative_path) + with ftp(self.collection) as client, BytesIO() as buff: + try: + client.retrbinary("RETR " + full_path, buff.write) + data = buff.getvalue() + except ftplib.Error as e: + raise FileNotFoundError(repr(e)) from e + return data + + def list(self, relative_path): + full_path = self._fullpath(relative_path) + with ftp(self.collection) as client: + try: + return client.nlst(full_path) + except OSError as e: + if e.errno == errno.ENOENT: + # The path do not exist return an empty list + return [] + else: + raise # pragma: no cover + + def move_files(self, files, destination_path): + _logger.debug("mv %s %s", files, destination_path) + fp = self._fullpath + with ftp(self.collection) as client: + for ftp_file in files: + dest_file_path = os.path.join( + destination_path, os.path.basename(ftp_file) + ) + # Remove existing file at the destination path (an error is raised + # otherwise) + result = [] + try: + result = client.nlst(dest_file_path) + except ftplib.Error: + _logger.debug("destination %s is free", dest_file_path) + if result: + client.delete(dest_file_path) + # Move the file using absolute filepaths + client.rename(fp(ftp_file), fp(dest_file_path)) + + def delete(self, relative_path): + full_path = self._fullpath(relative_path) + with ftp(self.collection) as client: + return client.delete(full_path) + + def validate_config(self): + with ftp(self.collection) as client: + client.getwelcome() diff --git a/storage_backend_ftp/i18n/it.po b/storage_backend_ftp/i18n/it.po new file mode 100644 index 0000000000..1feea75b5c --- /dev/null +++ b/storage_backend_ftp/i18n/it.po @@ -0,0 +1,134 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_backend_ftp +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-03-14 15:06+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.2\n" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__backend_type +msgid "Backend Type" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__tls_explicit +msgid "Explicit FTP over TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__backend_type__ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__ftp +#: model_terms:ir.ui.view,arch_db:storage_backend_ftp.storage_backend_view_form +msgid "FTP" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_encryption +msgid "FTP Encryption method" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_server +msgid "FTP Host" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_login +msgid "FTP Login" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_passive +msgid "FTP Passive" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_password +msgid "FTP Password" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_port +msgid "FTP Port" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_security +msgid "FTP security option" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__id +msgid "ID" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__tls +msgid "Implicit FTP over TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend____last_update +msgid "Last Modified on" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,help:storage_backend_ftp.field_storage_backend__ftp_login +msgid "Login to connect to ftp server" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__none +msgid "None" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv2 +msgid "SSLv2" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv23 +msgid "SSLv23" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv3 +msgid "SSLv3" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model,name:storage_backend_ftp.model_storage_backend +msgid "Storage Backend" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1 +msgid "TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1_1 +msgid "TLSv1_1" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1_2 +msgid "TLSv1_2" +msgstr "" diff --git a/storage_backend_ftp/i18n/storage_backend_ftp.pot b/storage_backend_ftp/i18n/storage_backend_ftp.pot new file mode 100644 index 0000000000..589cc22ac3 --- /dev/null +++ b/storage_backend_ftp/i18n/storage_backend_ftp.pot @@ -0,0 +1,131 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * storage_backend_ftp +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__backend_type +msgid "Backend Type" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__display_name +msgid "Display Name" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__tls_explicit +msgid "Explicit FTP over TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__backend_type__ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__ftp +#: model_terms:ir.ui.view,arch_db:storage_backend_ftp.storage_backend_view_form +msgid "FTP" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_encryption +msgid "FTP Encryption method" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_server +msgid "FTP Host" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_login +msgid "FTP Login" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_passive +msgid "FTP Passive" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_password +msgid "FTP Password" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_port +msgid "FTP Port" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__ftp_security +msgid "FTP security option" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend__id +msgid "ID" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_encryption__tls +msgid "Implicit FTP over TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,field_description:storage_backend_ftp.field_storage_backend____last_update +msgid "Last Modified on" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields,help:storage_backend_ftp.field_storage_backend__ftp_login +msgid "Login to connect to ftp server" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__none +msgid "None" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv2 +msgid "SSLv2" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv23 +msgid "SSLv23" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__sslv3 +msgid "SSLv3" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model,name:storage_backend_ftp.model_storage_backend +msgid "Storage Backend" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1 +msgid "TLS" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1_1 +msgid "TLSv1_1" +msgstr "" + +#. module: storage_backend_ftp +#: model:ir.model.fields.selection,name:storage_backend_ftp.selection__storage_backend__ftp_security__tlsv1_2 +msgid "TLSv1_2" +msgstr "" diff --git a/storage_backend_ftp/models/__init__.py b/storage_backend_ftp/models/__init__.py new file mode 100644 index 0000000000..f45f402268 --- /dev/null +++ b/storage_backend_ftp/models/__init__.py @@ -0,0 +1 @@ +from . import storage_backend diff --git a/storage_backend_ftp/models/storage_backend.py b/storage_backend_ftp/models/storage_backend.py new file mode 100644 index 0000000000..25fb0ea182 --- /dev/null +++ b/storage_backend_ftp/models/storage_backend.py @@ -0,0 +1,55 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class StorageBackend(models.Model): + _inherit = "storage.backend" + + backend_type = fields.Selection( + selection_add=[("ftp", "FTP")], ondelete={"ftp": "set default"} + ) + ftp_server = fields.Char(string="FTP Host") + ftp_port = fields.Integer(string="FTP Port", default=21) + ftp_encryption = fields.Selection( + string="FTP Encryption method", + selection=[ + ("ftp", "FTP"), + ("tls", "Implicit FTP over TLS"), + ("tls_explicit", "Explicit FTP over TLS"), + ], + default="ftp", + required=True, + ) + ftp_security = fields.Selection( + string="FTP security option", + selection=[ + ("none", "None"), + ("tlsv1", "TLS"), + ("tlsv1_1", "TLSv1_1"), + ("tlsv1_2", "TLSv1_2"), + ("sslv2", "SSLv2"), + ("sslv23", "SSLv23"), + ("sslv3", "SSLv3"), + ], + required=True, + ) + ftp_login = fields.Char(string="FTP Login", help="Login to connect to ftp server") + ftp_password = fields.Char(string="FTP Password") + ftp_passive = fields.Boolean(string="FTP Passive", default=False) + + @property + def _server_env_fields(self): + env_fields = super()._server_env_fields + env_fields.update( + { + "ftp_password": {}, + "ftp_login": {}, + "ftp_server": {}, + "ftp_port": {}, + "ftp_encryption": {}, + "ftp_security": {}, + "ftp_passive": {}, + } + ) + return env_fields diff --git a/storage_backend_ftp/pyproject.toml b/storage_backend_ftp/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/storage_backend_ftp/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/storage_backend_ftp/readme/CONTRIBUTORS.md b/storage_backend_ftp/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..25252eb455 --- /dev/null +++ b/storage_backend_ftp/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- François Honoré \ +- Lois Rilo \ +- thienvh \ diff --git a/storage_backend_ftp/readme/CREDITS.md b/storage_backend_ftp/readme/CREDITS.md new file mode 100644 index 0000000000..573d68b7e8 --- /dev/null +++ b/storage_backend_ftp/readme/CREDITS.md @@ -0,0 +1 @@ +The migration of this module from 14.0 to 18.0 was financially supported by Camptocamp. diff --git a/storage_backend_ftp/readme/DESCRIPTION.md b/storage_backend_ftp/readme/DESCRIPTION.md new file mode 100644 index 0000000000..b0b32036fc --- /dev/null +++ b/storage_backend_ftp/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Add FTP as storage backend diff --git a/storage_backend_ftp/static/description/icon.png b/storage_backend_ftp/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/storage_backend_ftp/static/description/icon.png differ diff --git a/storage_backend_ftp/static/description/index.html b/storage_backend_ftp/static/description/index.html new file mode 100644 index 0000000000..f53966e202 --- /dev/null +++ b/storage_backend_ftp/static/description/index.html @@ -0,0 +1,431 @@ + + + + + +Storage Backend FTP + + + +
+

Storage Backend FTP

+ + +

Beta License: LGPL-3 OCA/storage Translate me on Weblate Try me on Runboat

+

Add FTP as storage backend

+

Table of contents

+ +
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Acsone SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 14.0 to 18.0 was financially supported +by Camptocamp.

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

This module is part of the OCA/storage project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/storage_backend_ftp/tests/__init__.py b/storage_backend_ftp/tests/__init__.py new file mode 100644 index 0000000000..9ab6aa0f49 --- /dev/null +++ b/storage_backend_ftp/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ftp diff --git a/storage_backend_ftp/tests/test_ftp.py b/storage_backend_ftp/tests/test_ftp.py new file mode 100644 index 0000000000..96bb962c66 --- /dev/null +++ b/storage_backend_ftp/tests/test_ftp.py @@ -0,0 +1,163 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +# pylint: disable=missing-manifest-dependency +# disable warning on 'vcr' missing in manifest: this is only a dependency for +# dev/tests + +import errno +import ftplib +import logging +import os +from unittest import mock + +from odoo.addons.storage_backend.tests.common import BackendStorageTestMixin, CommonCase + +_logger = logging.getLogger(__name__) + +MOD_PATH = "odoo.addons.storage_backend_ftp.components.ftp_adapter" +ADAPTER_PATH = MOD_PATH + ".FTPStorageBackendAdapter" +FTP_LIB_PATH = MOD_PATH + ".ftplib" + + +class FtpCase(CommonCase, BackendStorageTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend.write( + { + "backend_type": "ftp", + "ftp_login": os.environ.get("FTP_LOGIN", "foo"), + "ftp_password": os.environ.get("FTP_PWD", "pass"), + "ftp_server": os.environ.get("FTP_HOST", "localhost"), + "ftp_port": os.environ.get("FTP_PORT", "21"), + "directory_path": "upload", + "ftp_encryption": "ftp", + } + ) + cls.case_with_subdirectory = "upload/subdirectory/here" + + @mock.patch(MOD_PATH + ".ftp_mkdirs") + @mock.patch(FTP_LIB_PATH) + def test_add(self, mocked_ftplib, mocked_mkdirs): + client = mocked_ftplib.FTP().__enter__() + # simulate errors + exc = OSError() + # general + client.cwd.side_effect = exc + with self.assertRaises(IOError): + self.backend.add("fake/path", b"fake data") + # not found + exc.errno = errno.ENOENT + client.cwd.side_effect = exc + file_data = b"fake data" + with mock.patch("io.BytesIO") as tmp_file: + self.backend.add("fake/path", file_data) + # mkdirs has been called + mocked_mkdirs.assert_called() + client.storbinary.assert_called() + tmp_file.assert_called() + tmp_file.assert_called_with(file_data) + client.storbinary.assert_called_with( + "STOR upload/fake/path", tmp_file().__enter__() + ) + + @mock.patch(FTP_LIB_PATH) + def test_get(self, mocked_ftplib): + client = mocked_ftplib.FTP().__enter__() + content = b"filecontent" + with open("/tmp/fakefile2.txt", "w+b") as fakefile: + fakefile.write(content) + + def side_effect_retrbinary(*args, **kwargs): + """ + Mock function to read tmp file. + """ + cmd, buff_write = args + with open("/tmp/fakefile2.txt", "rb") as tmp_file: + buff_write(tmp_file.read()) + + client.retrbinary.side_effect = side_effect_retrbinary + self.assertEqual(self.backend.get("fake/path"), content) + + @mock.patch(FTP_LIB_PATH) + def test_list(self, mocked_ftplib): + client = mocked_ftplib.FTP().__enter__() + # simulate errors + exc = OSError() + # general + client.nlst.side_effect = exc + with self.assertRaises(IOError): + self.backend.list_files() + # not found + exc.errno = errno.ENOENT + client.nlst.side_effect = exc + self.assertEqual(self.backend.list_files(), []) + + def test_find_files(self): + good_filepaths = ["somepath/file%d.good" % x for x in range(1, 10)] + bad_filepaths = ["somepath/file%d.bad" % x for x in range(1, 10)] + mocked_filepaths = bad_filepaths + good_filepaths + backend = self.backend.sudo() + expected = good_filepaths[:] + expected = [backend.directory_path + "/" + path for path in good_filepaths] + self._test_find_files( + backend, ADAPTER_PATH, mocked_filepaths, r".*\.good$", expected + ) + + # Do not patch the entire ftplib otherwise the error_perm Exception + # become also a mock and then a traceback is generated on the + # "except ftplib.error_perm" because this ftplib.error_perm + # is not really an Exception (but a mock)! + @mock.patch(FTP_LIB_PATH + ".FTP") + def test_move_files(self, mocked_ftplib): + client = mocked_ftplib().__enter__() + # simulate file is not already there + client.nlst.side_effect = ftplib.error_perm() + to_move = "move/from/path/myfile.txt" + to_path = "move/to/path" + self.backend.move_files([to_move], to_path) + # no need to delete it + client.delete.assert_not_called() + # rename gets called + client.rename.assert_called_with( + "upload/" + to_move, "upload/" + to_move.replace("from", "to") + ) + # now try to override destination + client.nlst.side_effect = None + client.nlst.return_value = True + self.backend.move_files([to_move], to_path) + # client will delete it first + client.delete.assert_called_with(to_move.replace("from", "to")) + # then move it + client.rename.assert_called_with( + "upload/" + to_move, "upload/" + to_move.replace("from", "to") + ) + + @mock.patch(FTP_LIB_PATH) + def test_delete(self, mocked_ftplib): + client = mocked_ftplib.FTP().__enter__() + path = "delete/a/path" + self.backend.delete(path) + client.delete.assert_called_with( + os.path.join(self.backend.directory_path, path) + ) + + @mock.patch(FTP_LIB_PATH) + def test_validate_config(self, mocked_ftplib): + client = mocked_ftplib.FTP().__enter__() + self.backend.action_test_config() + client.getwelcome.assert_called() + + @mock.patch(FTP_LIB_PATH) + def test_mkd(self, mocked_ftplib): + client = mocked_ftplib.FTP().__enter__() + # simulate errors + exc = OSError() + exc.errno = errno.ENOENT + # general + client.cwd.side_effect = exc + client.mkd.side_effect = exc + with self.assertRaises(OSError): + self.backend.add("fake/path", b"fake data") + client.mkd.assert_called() diff --git a/storage_backend_ftp/views/backend_storage_view.xml b/storage_backend_ftp/views/backend_storage_view.xml new file mode 100644 index 0000000000..ed6e36c9f4 --- /dev/null +++ b/storage_backend_ftp/views/backend_storage_view.xml @@ -0,0 +1,21 @@ + + + + storage.backend + + + + + + + + + + + + + + + + +