From f022064a478a192a92a3d7f30eb03853c32dacd7 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Mon, 24 Jun 2019 19:32:25 +0200 Subject: [PATCH 01/47] Migrate and refactore external_file_location --- attachment_synchronize/README.rst | 69 +++++++ attachment_synchronize/__init__.py | 1 + attachment_synchronize/__manifest__.py | 28 +++ attachment_synchronize/data/cron.xml | 16 ++ attachment_synchronize/demo/task_demo.xml | 95 ++++++++++ attachment_synchronize/models/__init__.py | 3 + attachment_synchronize/models/attachment.py | 26 +++ .../models/storage_backend.py | 11 ++ attachment_synchronize/models/task.py | 169 ++++++++++++++++++ .../security/ir.model.access.csv | 2 + .../static/description/icon.png | Bin 0 -> 9455 bytes attachment_synchronize/tests/__init__.py | 4 + attachment_synchronize/tests/common.py | 32 ++++ attachment_synchronize/tests/mock_server.py | 74 ++++++++ .../tests/test_filestore.py | 50 ++++++ attachment_synchronize/tests/test_ftp.py | 86 +++++++++ attachment_synchronize/tests/test_sftp.py | 85 +++++++++ .../views/attachment_view.xml | 26 +++ .../views/storage_backend_view.xml | 18 ++ attachment_synchronize/views/task_view.xml | 59 ++++++ 20 files changed, 854 insertions(+) create mode 100644 attachment_synchronize/README.rst create mode 100644 attachment_synchronize/__init__.py create mode 100644 attachment_synchronize/__manifest__.py create mode 100644 attachment_synchronize/data/cron.xml create mode 100644 attachment_synchronize/demo/task_demo.xml create mode 100644 attachment_synchronize/models/__init__.py create mode 100644 attachment_synchronize/models/attachment.py create mode 100644 attachment_synchronize/models/storage_backend.py create mode 100644 attachment_synchronize/models/task.py create mode 100644 attachment_synchronize/security/ir.model.access.csv create mode 100644 attachment_synchronize/static/description/icon.png create mode 100644 attachment_synchronize/tests/__init__.py create mode 100644 attachment_synchronize/tests/common.py create mode 100644 attachment_synchronize/tests/mock_server.py create mode 100644 attachment_synchronize/tests/test_filestore.py create mode 100644 attachment_synchronize/tests/test_ftp.py create mode 100644 attachment_synchronize/tests/test_sftp.py create mode 100644 attachment_synchronize/views/attachment_view.xml create mode 100644 attachment_synchronize/views/storage_backend_view.xml create mode 100644 attachment_synchronize/views/task_view.xml diff --git a/attachment_synchronize/README.rst b/attachment_synchronize/README.rst new file mode 100644 index 00000000000..2e34e98545e --- /dev/null +++ b/attachment_synchronize/README.rst @@ -0,0 +1,69 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +====================== +Attachment Synchronize +====================== + +This module was written to extend the functionality of ir.attachment to support remote communication and allow you to import/export file to a remote server. +For now, FTP, SFTP and local filestore are handled by the module. + +Installation +============ + +To install this module, you need to: + +* fs python module at version 0.5.4 or under +* Paramiko python module + +Usage +===== + +To use this module, you need to: + +* Add a location with your server infos +* Create a task with your file info and remote communication method +* A cron task will trigger each task + +.. 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/9.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 smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Valentin CHEMIERE +* Mourad EL HADJ MIMOUNE +* Florian DA COSTA + +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/attachment_synchronize/__init__.py b/attachment_synchronize/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/attachment_synchronize/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py new file mode 100644 index 00000000000..9479bedfb9b --- /dev/null +++ b/attachment_synchronize/__manifest__.py @@ -0,0 +1,28 @@ +# @ 2016 florian DA COSTA @ Akretion +# © 2016 @author Mourad EL HADJ MIMOUNE +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + 'name': 'Attachment Synchronize', + 'version': '12.0.1.0.0', + 'author': 'Akretion,Odoo Community Association (OCA)', + 'website': 'https://github.com/oca/server-tools', + 'license': 'AGPL-3', + 'category': 'Generic Modules', + 'depends': [ + 'base_attachment_queue', + 'storage_backend', + ], + 'data': [ + 'views/attachment_view.xml', + 'views/task_view.xml', + 'views/storage_backend_view.xml', + 'data/cron.xml', + 'security/ir.model.access.csv', + ], + 'demo': [ +# 'demo/task_demo.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/attachment_synchronize/data/cron.xml b/attachment_synchronize/data/cron.xml new file mode 100644 index 00000000000..ab2f883f248 --- /dev/null +++ b/attachment_synchronize/data/cron.xml @@ -0,0 +1,16 @@ + + + + + Run attachment tasks import + 30 + minutes + -1 + False + + + code + model.run_task_scheduler([('method_type', '=', 'import')]) + + + diff --git a/attachment_synchronize/demo/task_demo.xml b/attachment_synchronize/demo/task_demo.xml new file mode 100644 index 00000000000..e6954c94370 --- /dev/null +++ b/attachment_synchronize/demo/task_demo.xml @@ -0,0 +1,95 @@ + + + + + TEST FTP + ftp + my-ftp-address + my-ftp-user + my-ftp-password + 21 + + + TEST SFTP + sftp + my-sftp-address + my-sftp-user + my-sftp-password + 22 + + + TEST File Store + file_store + / + + + + import + + test-import-ftp.txt + /home/user/test + Import FTP Task + + + + export + + /home/user/test + Export FTP Task + + + + import + + test-import-sftp.txt + /home/user/test + Import SFTP Task + + + + export + + /home/user/test + Export SFTP Task + + + + import + + test-import-filestore.txt + /home/user/test + Import filestore Task + + + + export + + /home/user/test + Export filestore Task + + + + Sftp text export file + dGVzdCBzZnRwIGZpbGUgZXhwb3J0 + sftp_test_export.txt + + export_external_location + + + + ftp text export file + dGVzdCBmdHAgZmlsZSBleHBvcnQ= + ftp_test_export.txt + + export_external_location + + + + filestore text export file + dGVzdCBmaWxlc3RvcmUgZmlsZSBleHBvcnQ= + filestore_test_export.txt + + export_external_location + + + diff --git a/attachment_synchronize/models/__init__.py b/attachment_synchronize/models/__init__.py new file mode 100644 index 00000000000..9c2603f1723 --- /dev/null +++ b/attachment_synchronize/models/__init__.py @@ -0,0 +1,3 @@ +from . import attachment +from . import task +from . import storage_backend diff --git a/attachment_synchronize/models/attachment.py b/attachment_synchronize/models/attachment.py new file mode 100644 index 00000000000..231cd518193 --- /dev/null +++ b/attachment_synchronize/models/attachment.py @@ -0,0 +1,26 @@ +# @ 2016 Florian DA COSTA @ Akretion +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models, fields, api +import os + + +class IrAttachmentMetadata(models.Model): + _inherit = 'ir.attachment.metadata' + + task_id = fields.Many2one('storage.backend.task', string='Task') + storage_backend_id = fields.Many2one( + 'storage.backend', string='Storage Backend', + related='task_id.backend_id', store=True) + file_type = fields.Selection( + selection_add=[ + ('export', + 'Export File (External location)') + ]) + + @api.multi + def _run(self): + super(IrAttachmentMetadata, self)._run() + if self.file_type == 'export': + path = os.path.join(self.task_id.filepath, self.datas_fname) + self.storage_backend_id._add_b64_data(path, self.datas) diff --git a/attachment_synchronize/models/storage_backend.py b/attachment_synchronize/models/storage_backend.py new file mode 100644 index 00000000000..b77a304db45 --- /dev/null +++ b/attachment_synchronize/models/storage_backend.py @@ -0,0 +1,11 @@ +# 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" + + task_ids = fields.One2many( + "storage.backend.task", "backend_id", + string="Tasks") diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py new file mode 100644 index 00000000000..a828bc7e5e0 --- /dev/null +++ b/attachment_synchronize/models/task.py @@ -0,0 +1,169 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models, fields, api +import odoo +from odoo import tools +from base64 import b64encode +import os +import datetime +import logging + +_logger = logging.getLogger(__name__) + + +try: + # We use a jinja2 sandboxed environment to render mako templates. + # Note that the rendering does not cover all the mako syntax, in particular + # arbitrary Python statements are not accepted, and not all expressions are + # allowed: only "public" attributes (not starting with '_') of objects may + # be accessed. + # This is done on purpose: it prevents incidental or malicious execution of + # Python code that may break the security of the server. + from jinja2.sandbox import SandboxedEnvironment + mako_template_env = SandboxedEnvironment( + variable_start_string="${", + variable_end_string="}", + line_statement_prefix="%", + trim_blocks=True, # do not output newline after blocks + ) + mako_template_env.globals.update({ + 'str': str, + 'datetime': datetime, + 'len': len, + 'abs': abs, + 'min': min, + 'max': max, + 'sum': sum, + 'filter': filter, + 'map': map, + 'round': round, + }) +except ImportError: + _logger.warning("jinja2 not available, templating features will not work!") + + +class StorageTask(models.Model): + _name = 'storage.backend.task' + _description = 'Storage Backend task' + + name = fields.Char(required=True) + method_type = fields.Selection( + [('import', 'Import'), ('export', 'Export')], + required=True) + filename = fields.Char(help='File name which is imported.' + 'The system will check if the remote file at ' + 'least contains the pattern in its name. ' + 'Leave it empty to import all files') + filepath = fields.Char(help='Path to imported/exported file') + backend_id = fields.Many2one( + 'storage.backend', string='Backend', required=True) + attachment_ids = fields.One2many('ir.attachment.metadata', 'task_id', + string='Attachment') + move_path = fields.Char(string='Move Path', + help='Imported File will be moved to this path') + new_name = fields.Char(string='New Name', + help='Imported File will be renamed to this name\n' + 'Name can use mako template where obj is an ' + 'ir_attachement. template exemple : ' + ' ${obj.name}-${obj.create_date}.csv') + after_import = fields.Selection( + selection=[ + ('rename', 'Rename'), + ('move', 'Move'), + ('move_rename', 'Move & Rename'), + ('delete', 'Delete'), + ], help='Action after import a file') + file_type = fields.Selection( + selection=[], + string="File Type", + help="The file type determines an import method to be used " + "to parse and transform data before their import in ERP") + active = fields.Boolean(default=True) + + @api.multi + def _prepare_attachment_vals(self, datas, filename): + self.ensure_one() + vals = { + 'name': filename, + 'datas': datas, + 'datas_fname': filename, + 'task_id': self.id, + 'file_type': self.file_type or False, + } + return vals + + @api.model + def _template_render(self, template, record): + try: + template = mako_template_env.from_string(tools.ustr(template)) + except Exception: + _logger.exception("Failed to load template %r", template) + + variables = {'obj': record} + try: + render_result = template.render(variables) + except Exception: + _logger.exception( + "Failed to render template %r using values %r" % + (template, variables)) + render_result = u"" + if render_result == u"False": + render_result = u"" + return render_result + + @api.model + def run_task_scheduler(self, domain=None): + if domain is None: + domain = [] + domain.append([('method_type', '=', 'import')]) + tasks = self.env['storage.backend'].search(domain) + for task in tasks: + task.run_import() + + @api.multi + def run_import(self): + self.ensure_one() + attach_obj = self.env['ir.attachment.metadata'] + backend = self.backend_id + all_filenames = backend._list(relative_path=self.filepath) + if self.filename: + filenames = [x for x in all_filenames if self.filename in x] + for file_name in filenames: + with api.Environment.manage(): + with odoo.registry( + self.env.cr.dbname).cursor() as new_cr: + new_env = api.Environment(new_cr, self.env.uid, + self.env.context) + try: + full_absolute_path = os.path.join( + self.filepath, file_name) + datas = backend._get_b64_data(full_absolute_path) + attach_vals = self._prepare_attachment_vals( + datas, file_name) + attachment = attach_obj.with_env(new_env).create( + attach_vals) + new_full_path = False + if self.after_import == 'rename': + new_name = self._template_render( + self.new_name, attachment) + new_full_path = os.path.join( + self.filepath, new_name) + elif self.after_import == 'move': + new_full_path = os.path.join( + self.move_path, file_name) + elif self.after_import == 'move_rename': + new_name = self._template_render( + self.new_name, attachment) + new_full_path = os.path.join( + self.move_path, new_name) + if new_full_path: + backend._add_b64_data(new_full_path, datas) + if self.after_import in ( + 'delete', 'rename', 'move', 'move_rename' + ): + backend._delete(full_absolute_path) + except Exception as e: + new_env.cr.rollback() + raise e + else: + new_env.cr.commit() diff --git a/attachment_synchronize/security/ir.model.access.csv b/attachment_synchronize/security/ir.model.access.csv new file mode 100644 index 00000000000..5d4973cecb1 --- /dev/null +++ b/attachment_synchronize/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_storage_backend_task_manager,storage.backend.task.manager,model_storage_backend_task,base.group_system,1,1,1,1 diff --git a/attachment_synchronize/static/description/icon.png b/attachment_synchronize/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/attachment_synchronize/tests/__init__.py b/attachment_synchronize/tests/__init__.py new file mode 100644 index 00000000000..bbfd6ecb329 --- /dev/null +++ b/attachment_synchronize/tests/__init__.py @@ -0,0 +1,4 @@ +from . import mock_server +from . import test_ftp +from . import test_sftp +from . import test_filestore diff --git a/attachment_synchronize/tests/common.py b/attachment_synchronize/tests/common.py new file mode 100644 index 00000000000..b04c901acbb --- /dev/null +++ b/attachment_synchronize/tests/common.py @@ -0,0 +1,32 @@ +# coding: utf-8 +# @ 2016 Florian da Costa @ Akretion +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import openerp.tests.common as common +from openerp import api +from StringIO import StringIO + + +class ContextualStringIO(StringIO): + """ + snippet from http://bit.ly/1HfH6uW (stackoverflow) + """ + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + return False + + +class TestConnection(common.TransactionCase): + + def setUp(self): + super(TestConnection, self).setUp() + self.registry.enter_test_mode() + self.env = api.Environment(self.registry.test_cr, self.env.uid, + self.env.context) + + def tearDown(self): + self.registry.leave_test_mode() + super(TestConnection, self).tearDown() diff --git a/attachment_synchronize/tests/mock_server.py b/attachment_synchronize/tests/mock_server.py new file mode 100644 index 00000000000..b024cf60c65 --- /dev/null +++ b/attachment_synchronize/tests/mock_server.py @@ -0,0 +1,74 @@ +# coding: utf-8 +# Copyright (C) 2014 initOS GmbH & Co. KG (). +# @ 2015 Valentin CHEMIERE @ Akretion +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import mock +from contextlib import contextmanager +from collections import defaultdict + + +class MultiResponse(dict): + pass + + +class ConnMock(object): + + def __init__(self, response): + self.response = response + self._calls = [] + self.call_count = defaultdict(int) + + def __getattribute__(self, method): + if method not in ('_calls', 'response', 'call_count'): + def callable(*args, **kwargs): + self._calls.append({ + 'method': method, + 'args': args, + 'kwargs': kwargs, + }) + call = self.response[method] + if isinstance(call, MultiResponse): + call = call[self.call_count[method]] + self.call_count[method] += 1 + return call + + return callable + else: + return super(ConnMock, self).__getattribute__(method) + + def __call__(self, *args, **kwargs): + return self + + def __enter__(self, *args, **kwargs): + return self + + def __exit__(self, *args, **kwargs): + pass + + def __repr__(self, *args, **kwargs): + return self + + def __getitem__(self, key): + return + + +@contextmanager +def server_mock_sftp(response): + with mock.patch('openerp.addons.external_file_location.tasks.sftp.' + 'SftpTask', ConnMock(response)) as SFTPFS: + yield SFTPFS._calls + + +@contextmanager +def server_mock_ftp(response): + with mock.patch('openerp.addons.external_file_location.tasks.ftp.' + 'FtpTask', ConnMock(response)) as FTPFS: + yield FTPFS._calls + + +@contextmanager +def server_mock_filestore(response): + with mock.patch('openerp.addons.external_file_location.tasks.filestore.' + 'FileStoreTask', ConnMock(response)) as FTPFS: + yield FTPFS._calls diff --git a/attachment_synchronize/tests/test_filestore.py b/attachment_synchronize/tests/test_filestore.py new file mode 100644 index 00000000000..9091cee42f2 --- /dev/null +++ b/attachment_synchronize/tests/test_filestore.py @@ -0,0 +1,50 @@ +# coding: utf-8 +# @ 2015 Valentin CHEMIERE @ Akretion +# ©2016 @author Mourad EL HADJ MIMOUNE +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging +from base64 import b64decode +from .common import TestConnection, ContextualStringIO +from .mock_server import server_mock_filestore + + +_logger = logging.getLogger(__name__) + + +class TestfilestoreConnection(TestConnection): + + def setUp(self): + super(TestfilestoreConnection, self).setUp() + self.test_file_filestore = ContextualStringIO() + self.test_file_filestore.write('import filestore') + self.test_file_filestore.seek(0) + + def test_00_filestore_import(self): + self.task = self.env.ref( + 'external_file_location.filestore_import_task') + with server_mock_filestore( + {'open': self.test_file_filestore, + 'listdir': ['test-import-filestore.txt']}): + self.task.run_import() + search_file = self.env['ir.attachment.metadata'].search( + [('name', '=', 'test-import-filestore.txt')]) + self.assertEqual(len(search_file), 1) + self.assertEqual(b64decode(search_file[0].datas), 'import filestore') + + def test_01_filestore_export(self): + self.task = self.env.ref( + 'external_file_location.filestore_export_task') + self.filestore_attachment = self.env.ref( + 'external_file_location.ir_attachment_export_file_filestore') + with server_mock_filestore( + {'setcontents': ''}) as Fakefilestore: + self.task.run_export() + if Fakefilestore: + self.assertEqual('setcontents', Fakefilestore[-1]['method']) + self.assertEqual('done', self.filestore_attachment.state) + self.assertEqual( + '/home/user/test/filestore_test_export.txt', + Fakefilestore[-1]['args'][0]) + self.assertEqual( + 'test filestore file export', + Fakefilestore[-1]['kwargs']['data']) diff --git a/attachment_synchronize/tests/test_ftp.py b/attachment_synchronize/tests/test_ftp.py new file mode 100644 index 00000000000..c5ce603b723 --- /dev/null +++ b/attachment_synchronize/tests/test_ftp.py @@ -0,0 +1,86 @@ +# coding: utf-8 +# @ 2015 Valentin CHEMIERE @ Akretion +# ©2016 @author Mourad EL HADJ MIMOUNE +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging +from base64 import b64decode +import hashlib +from .common import TestConnection, ContextualStringIO +from .mock_server import server_mock_ftp +from .mock_server import MultiResponse +from openerp.exceptions import UserError + + +_logger = logging.getLogger(__name__) + + +class TestFtpConnection(TestConnection): + + def setUp(self): + super(TestFtpConnection, self).setUp() + self.test_file_ftp = ContextualStringIO() + self.test_file_ftp.write('import ftp') + self.test_file_ftp.seek(0) + + def test_00_ftp_import(self): + self.task = self.env.ref('external_file_location.ftp_import_task') + with server_mock_ftp( + {'open': self.test_file_ftp, + 'listdir': ['test-import-ftp.txt']}): + self.task.run_import() + search_file = self.env['ir.attachment.metadata'].search( + [('name', '=', 'test-import-ftp.txt')]) + self.assertEqual(len(search_file), 1) + self.assertEqual(b64decode(search_file[0].datas), 'import ftp') + + def test_01_ftp_export(self): + self.task = self.env.ref('external_file_location.ftp_export_task') + self.ftp_attachment = self.env.ref( + 'external_file_location.ir_attachment_export_file_ftp') + with server_mock_ftp( + {'setcontents': ''}) as FakeFTP: + self.task.run_export() + if FakeFTP: + self.assertEqual('setcontents', FakeFTP[-1]['method']) + self.assertEqual('done', self.ftp_attachment.state) + self.assertEqual( + '/home/user/test/ftp_test_export.txt', + FakeFTP[-1]['args'][0]) + self.assertEqual( + 'test ftp file export', + FakeFTP[-1]['kwargs']['data']) + + def test_02_ftp_import_md5(self): + md5_file = ContextualStringIO() + md5_file.write(hashlib.md5('import ftp').hexdigest()) + md5_file.seek(0) + task = self.env.ref('external_file_location.ftp_import_task') + task.md5_check = True + with server_mock_ftp( + {'open': MultiResponse({ + 1: md5_file, + 0: self.test_file_ftp}), + 'listdir': [task.filename]}) as Fakeftp: + task.run_import() + search_file = self.env['ir.attachment.metadata'].search( + (('name', '=', task.filename),)) + self.assertEqual(len(search_file), 1) + self.assertEqual(b64decode(search_file[0].datas), + 'import ftp') + self.assertEqual('open', Fakeftp[-1]['method']) + self.assertEqual(hashlib.md5('import ftp').hexdigest(), + search_file.external_hash) + + def test_03_ftp_import_md5_corrupt_file(self): + md5_file = ContextualStringIO() + md5_file.write(hashlib.md5('import test ftp corrupted').hexdigest()) + md5_file.seek(0) + task = self.env.ref('external_file_location.ftp_import_task') + task.md5_check = True + with server_mock_ftp( + {'open': MultiResponse({ + 1: md5_file, + 0: self.test_file_ftp}), + 'listdir': [task.filename]}): + with self.assertRaises(UserError): + task.run_import() diff --git a/attachment_synchronize/tests/test_sftp.py b/attachment_synchronize/tests/test_sftp.py new file mode 100644 index 00000000000..e3f128db022 --- /dev/null +++ b/attachment_synchronize/tests/test_sftp.py @@ -0,0 +1,85 @@ +# coding: utf-8 +# @ 2015 Valentin CHEMIERE @ Akretion +# ©2016 @author Mourad EL HADJ MIMOUNE +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging +from base64 import b64decode +import hashlib +from .common import TestConnection, ContextualStringIO +from .mock_server import server_mock_sftp +from .mock_server import MultiResponse +from openerp.exceptions import UserError + + +_logger = logging.getLogger(__name__) + + +class TestSftpConnection(TestConnection): + + def setUp(self): + super(TestSftpConnection, self).setUp() + self.test_file_sftp = ContextualStringIO() + self.test_file_sftp.write('import sftp') + self.test_file_sftp.seek(0) + + def test_00_sftp_import(self): + task = self.env.ref('external_file_location.sftp_import_task') + with server_mock_sftp( + {'open': self.test_file_sftp, + 'listdir': [task.filename]}): + task.run_import() + search_file = self.env['ir.attachment.metadata'].search( + [('name', '=', task.filename)]) + self.assertEqual(len(search_file), 1) + self.assertEqual(b64decode(search_file[0].datas), 'import sftp') + + def test_01_sftp_export(self): + self.task = self.env.ref('external_file_location.sftp_export_task') + self.sftp_attachment = self.env.ref( + 'external_file_location.ir_attachment_export_file_sftp') + with server_mock_sftp( + {'setcontents': ''}) as FakeSFTP: + self.task.run_export() + if FakeSFTP: + self.assertEqual('setcontents', FakeSFTP[-1]['method']) + self.assertEqual( + '/home/user/test/sftp_test_export.txt', + FakeSFTP[-1]['args'][0]) + self.assertEqual( + 'test sftp file export', + FakeSFTP[-1]['kwargs']['data']) + + def test_02_sftp_import_md5(self): + md5_file = ContextualStringIO() + md5_file.write(hashlib.md5('import sftp').hexdigest()) + md5_file.seek(0) + task = self.env.ref('external_file_location.sftp_import_task') + task.md5_check = True + with server_mock_sftp( + {'open': MultiResponse({ + 1: md5_file, + 0: self.test_file_sftp}), + 'listdir': [task.filename]}) as FakeSFTP: + task.run_import() + search_file = self.env['ir.attachment.metadata'].search( + (('name', '=', task.filename),)) + self.assertEqual(len(search_file), 1) + self.assertEqual(b64decode(search_file[0].datas), + 'import sftp') + self.assertEqual('open', FakeSFTP[-1]['method']) + self.assertEqual(hashlib.md5('import sftp').hexdigest(), + search_file.external_hash) + + def test_03_sftp_import_md5_corrupt_file(self): + md5_file = ContextualStringIO() + md5_file.write(hashlib.md5('import test sftp corrupted').hexdigest()) + md5_file.seek(0) + task = self.env.ref('external_file_location.sftp_import_task') + task.md5_check = True + with server_mock_sftp( + {'open': MultiResponse({ + 1: md5_file, + 0: self.test_file_sftp}), + 'listdir': [task.filename]}): + with self.assertRaises(UserError): + task.run_import() diff --git a/attachment_synchronize/views/attachment_view.xml b/attachment_synchronize/views/attachment_view.xml new file mode 100644 index 00000000000..e9e4ce54a6f --- /dev/null +++ b/attachment_synchronize/views/attachment_view.xml @@ -0,0 +1,26 @@ + + + + + ir.attachment.metadata + + + + + + + + + + + ir.attachment.metadata + + + + + + + + + + diff --git a/attachment_synchronize/views/storage_backend_view.xml b/attachment_synchronize/views/storage_backend_view.xml new file mode 100644 index 00000000000..58bd22d405d --- /dev/null +++ b/attachment_synchronize/views/storage_backend_view.xml @@ -0,0 +1,18 @@ + + + + + storage.backend + + + +
+ + + + +
+
+
+ +
diff --git a/attachment_synchronize/views/task_view.xml b/attachment_synchronize/views/task_view.xml new file mode 100644 index 00000000000..0a0dd911adc --- /dev/null +++ b/attachment_synchronize/views/task_view.xml @@ -0,0 +1,59 @@ + + + + + storage.backend.task + +
+
+
+ + + +
+
+ + + + +
+ + + + + + + + + + + +
+
+
+
+ + + storage.backend.task + + + + + + + + + + +
From 945e3f5279aaaa56e683b5a5e95bd6ca94b6ceeb Mon Sep 17 00:00:00 2001 From: Giovanni Date: Tue, 14 Jan 2020 10:44:09 +0100 Subject: [PATCH 02/47] - better view layout - match pattern filename to download - empty list as default domain --- attachment_synchronize/models/task.py | 22 +++++----- .../views/attachment_view.xml | 36 +++++++-------- .../views/storage_backend_view.xml | 17 +++---- attachment_synchronize/views/task_view.xml | 44 +++++++------------ 4 files changed, 55 insertions(+), 64 deletions(-) diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index a828bc7e5e0..73b3de06196 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -1,12 +1,12 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import models, fields, api -import odoo -from odoo import tools -from base64 import b64encode -import os import datetime import logging +import os +from fnmatch import fnmatch + +import odoo +from odoo import api, fields, models, tools _logger = logging.getLogger(__name__) @@ -112,10 +112,10 @@ def _template_render(self, template, record): return render_result @api.model - def run_task_scheduler(self, domain=None): - if domain is None: - domain = [] - domain.append([('method_type', '=', 'import')]) + def run_task_scheduler(self, domain=list()): + if ('method_type', '=', 'import') not in domain: + domain.append([('method_type', '=', 'import')]) + tasks = self.env['storage.backend'].search(domain) for task in tasks: task.run_import() @@ -127,7 +127,7 @@ def run_import(self): backend = self.backend_id all_filenames = backend._list(relative_path=self.filepath) if self.filename: - filenames = [x for x in all_filenames if self.filename in x] + filenames = [x for x in all_filenames if fnmatch(x, self.filename)] for file_name in filenames: with api.Environment.manage(): with odoo.registry( @@ -160,7 +160,7 @@ def run_import(self): backend._add_b64_data(new_full_path, datas) if self.after_import in ( 'delete', 'rename', 'move', 'move_rename' - ): + ): backend._delete(full_absolute_path) except Exception as e: new_env.cr.rollback() diff --git a/attachment_synchronize/views/attachment_view.xml b/attachment_synchronize/views/attachment_view.xml index e9e4ce54a6f..6e4c91766d6 100644 --- a/attachment_synchronize/views/attachment_view.xml +++ b/attachment_synchronize/views/attachment_view.xml @@ -1,26 +1,26 @@ - - ir.attachment.metadata - - - - - - + + ir.attachment.metadata + + + + + - + + - - ir.attachment.metadata - - - - - - + + ir.attachment.metadata + + + + + - + + diff --git a/attachment_synchronize/views/storage_backend_view.xml b/attachment_synchronize/views/storage_backend_view.xml index 58bd22d405d..143aa1b467f 100644 --- a/attachment_synchronize/views/storage_backend_view.xml +++ b/attachment_synchronize/views/storage_backend_view.xml @@ -3,15 +3,16 @@ storage.backend - - + + -
- - - - -
+ + + + + + +
diff --git a/attachment_synchronize/views/task_view.xml b/attachment_synchronize/views/task_view.xml index 0a0dd911adc..95b6f42917b 100644 --- a/attachment_synchronize/views/task_view.xml +++ b/attachment_synchronize/views/task_view.xml @@ -1,40 +1,30 @@ - storage.backend.task
-
- -
-
- - - - +
+
+ + + + + - + - - - - - + + @@ -47,10 +37,10 @@ storage.backend.task - + - + From 1c2b4b7b6b4c068b0c6c5008eaffd5e8ce4923b2 Mon Sep 17 00:00:00 2001 From: Giovanni Date: Wed, 15 Jan 2020 11:21:49 +0100 Subject: [PATCH 03/47] - pass pattern to _list --- attachment_synchronize/models/task.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 73b3de06196..42bcb6eac22 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -3,7 +3,6 @@ import datetime import logging import os -from fnmatch import fnmatch import odoo from odoo import api, fields, models, tools @@ -50,10 +49,10 @@ class StorageTask(models.Model): method_type = fields.Selection( [('import', 'Import'), ('export', 'Export')], required=True) - filename = fields.Char(help='File name which is imported.' - 'The system will check if the remote file at ' - 'least contains the pattern in its name. ' - 'Leave it empty to import all files') + pattern = fields.Char(help='File name which is imported.' + 'The system will check if the remote file at ' + 'least contains the pattern in its name. ' + 'Leave it empty to import all files') filepath = fields.Char(help='Path to imported/exported file') backend_id = fields.Many2one( 'storage.backend', string='Backend', required=True) @@ -125,9 +124,8 @@ def run_import(self): self.ensure_one() attach_obj = self.env['ir.attachment.metadata'] backend = self.backend_id - all_filenames = backend._list(relative_path=self.filepath) - if self.filename: - filenames = [x for x in all_filenames if fnmatch(x, self.filename)] + filenames = backend._list( + relative_path=self.filepath, pattern=self.pattern) for file_name in filenames: with api.Environment.manage(): with odoo.registry( From 810aba129abb442aa1c9b22a9052e3ad1d6c3888 Mon Sep 17 00:00:00 2001 From: Giovanni Date: Wed, 15 Jan 2020 15:00:44 +0100 Subject: [PATCH 04/47] - check for duplicates - prefer enabled in place of active --- attachment_synchronize/models/task.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 42bcb6eac22..54f4d86601c 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -77,7 +77,10 @@ class StorageTask(models.Model): string="File Type", help="The file type determines an import method to be used " "to parse and transform data before their import in ERP") - active = fields.Boolean(default=True) + enabled = fields.Boolean('Enabled', default=True) + check_duplicated_files = fields.Boolean( + string='Check duplicated files', + help='If checked, will avoid duplication file import') @api.multi def _prepare_attachment_vals(self, datas, filename): @@ -114,7 +117,7 @@ def _template_render(self, template, record): def run_task_scheduler(self, domain=list()): if ('method_type', '=', 'import') not in domain: domain.append([('method_type', '=', 'import')]) - + domain.append([('enabled', '=', True)]) tasks = self.env['storage.backend'].search(domain) for task in tasks: task.run_import() @@ -126,6 +129,8 @@ def run_import(self): backend = self.backend_id filenames = backend._list( relative_path=self.filepath, pattern=self.pattern) + if self.check_duplicated_files: + filenames = self._file_to_import(filenames) for file_name in filenames: with api.Environment.manage(): with odoo.registry( @@ -165,3 +170,7 @@ def run_import(self): raise e else: new_env.cr.commit() + + def _file_to_import(self, filenames): + imported = self.attachment_ids.search([('name', 'in', [n for n in filenames])]).mapped('name') + return list(set(filenames) - set(imported)) From 84f3e8dccadf3c060d4ea8aff61dda939bbacfdf Mon Sep 17 00:00:00 2001 From: Giovanni Date: Wed, 15 Jan 2020 18:41:48 +0100 Subject: [PATCH 05/47] - use filtered.mapped in place of search --- attachment_synchronize/README.rst | 1 + attachment_synchronize/__manifest__.py | 3 ++- attachment_synchronize/models/task.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/attachment_synchronize/README.rst b/attachment_synchronize/README.rst index 2e34e98545e..cd377e983a3 100644 --- a/attachment_synchronize/README.rst +++ b/attachment_synchronize/README.rst @@ -52,6 +52,7 @@ Contributors * Valentin CHEMIERE * Mourad EL HADJ MIMOUNE * Florian DA COSTA +* Giovanni SERRA Maintainer ---------- diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index 9479bedfb9b..418efa80fe3 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -1,5 +1,6 @@ # @ 2016 florian DA COSTA @ Akretion # © 2016 @author Mourad EL HADJ MIMOUNE +# @ 2020 Giovanni Serra @ GSlab.it # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { @@ -21,7 +22,7 @@ 'security/ir.model.access.csv', ], 'demo': [ -# 'demo/task_demo.xml', + # 'demo/task_demo.xml', ], 'installable': True, 'application': False, diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 54f4d86601c..7a30e4b2ec6 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -172,5 +172,5 @@ def run_import(self): new_env.cr.commit() def _file_to_import(self, filenames): - imported = self.attachment_ids.search([('name', 'in', [n for n in filenames])]).mapped('name') + imported = self.attachment_ids.filtered(lambda r: r.name in filenames).mapped('name') return list(set(filenames) - set(imported)) From 02a85b61b0df2cbea9f5f95d95b6e1cd47358047 Mon Sep 17 00:00:00 2001 From: Giovanni Date: Fri, 17 Jan 2020 23:00:16 +0100 Subject: [PATCH 06/47] - fix task view --- attachment_synchronize/views/task_view.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/attachment_synchronize/views/task_view.xml b/attachment_synchronize/views/task_view.xml index 95b6f42917b..97d0d0dc344 100644 --- a/attachment_synchronize/views/task_view.xml +++ b/attachment_synchronize/views/task_view.xml @@ -18,7 +18,7 @@ - + @@ -40,7 +40,7 @@ - + From 31e608993be7fdc2423a714ff2610a101a2baefc Mon Sep 17 00:00:00 2001 From: Giovanni Date: Mon, 20 Jan 2020 13:03:19 +0100 Subject: [PATCH 07/47] - fix check_duplicated_files in view - log info on run completed --- attachment_synchronize/models/task.py | 3 +++ attachment_synchronize/views/task_view.xml | 1 + 2 files changed, 4 insertions(+) diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 7a30e4b2ec6..10ac78d556a 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -131,6 +131,7 @@ def run_import(self): relative_path=self.filepath, pattern=self.pattern) if self.check_duplicated_files: filenames = self._file_to_import(filenames) + total_import = 0 for file_name in filenames: with api.Environment.manage(): with odoo.registry( @@ -165,11 +166,13 @@ def run_import(self): 'delete', 'rename', 'move', 'move_rename' ): backend._delete(full_absolute_path) + total_import += 1 except Exception as e: new_env.cr.rollback() raise e else: new_env.cr.commit() + _logger.info('Run import complete! Imported {0} files'.format(total_import)) def _file_to_import(self, filenames): imported = self.attachment_ids.filtered(lambda r: r.name in filenames).mapped('name') diff --git a/attachment_synchronize/views/task_view.xml b/attachment_synchronize/views/task_view.xml index 97d0d0dc344..198dacb832b 100644 --- a/attachment_synchronize/views/task_view.xml +++ b/attachment_synchronize/views/task_view.xml @@ -20,6 +20,7 @@ + From d91060157d4a5073e49a49b444dd83ff1cacac59 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Mon, 10 Feb 2020 14:18:57 +0100 Subject: [PATCH 08/47] Allow to send mail notification if attachment linked to task fail --- attachment_synchronize/models/attachment.py | 9 +++++++-- attachment_synchronize/models/task.py | 4 ++++ attachment_synchronize/views/task_view.xml | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/attachment_synchronize/models/attachment.py b/attachment_synchronize/models/attachment.py index 231cd518193..926b5a6e6ab 100644 --- a/attachment_synchronize/models/attachment.py +++ b/attachment_synchronize/models/attachment.py @@ -18,9 +18,14 @@ class IrAttachmentMetadata(models.Model): 'Export File (External location)') ]) - @api.multi def _run(self): - super(IrAttachmentMetadata, self)._run() + super()._run() if self.file_type == 'export': path = os.path.join(self.task_id.filepath, self.datas_fname) self.storage_backend_id._add_b64_data(path, self.datas) + + def _get_failure_emails(self): + res = super()._get_failure_emails() + if self.task_id.emails: + res = self.task_id.emails + return res diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 10ac78d556a..4641a1b1d77 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -81,6 +81,10 @@ class StorageTask(models.Model): check_duplicated_files = fields.Boolean( string='Check duplicated files', help='If checked, will avoid duplication file import') + emails = fields.Char( + string="Emails", + help="list of email which should be notified in case of failure " + "when excuting the files linked to this task") @api.multi def _prepare_attachment_vals(self, datas, filename): diff --git a/attachment_synchronize/views/task_view.xml b/attachment_synchronize/views/task_view.xml index 198dacb832b..9195143aa44 100644 --- a/attachment_synchronize/views/task_view.xml +++ b/attachment_synchronize/views/task_view.xml @@ -21,6 +21,7 @@ + From 75d7af3137812987ed7f35c8e2a552fd4167e576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 11 May 2020 16:41:51 +0200 Subject: [PATCH 09/47] [REF] start cleanning --- attachment_synchronize/__manifest__.py | 3 +- attachment_synchronize/demo/task_demo.xml | 95 ------------------- attachment_synchronize/models/attachment.py | 4 +- attachment_synchronize/models/task.py | 20 ++-- attachment_synchronize/tests/__init__.py | 4 - attachment_synchronize/tests/common.py | 32 ------- attachment_synchronize/tests/mock_server.py | 74 --------------- .../tests/test_filestore.py | 50 ---------- attachment_synchronize/tests/test_ftp.py | 86 ----------------- attachment_synchronize/tests/test_sftp.py | 85 ----------------- .../views/attachment_view.xml | 12 +-- 11 files changed, 20 insertions(+), 445 deletions(-) delete mode 100644 attachment_synchronize/demo/task_demo.xml delete mode 100644 attachment_synchronize/tests/common.py delete mode 100644 attachment_synchronize/tests/mock_server.py delete mode 100644 attachment_synchronize/tests/test_filestore.py delete mode 100644 attachment_synchronize/tests/test_ftp.py delete mode 100644 attachment_synchronize/tests/test_sftp.py diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index 418efa80fe3..9594dbd1346 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -11,7 +11,7 @@ 'license': 'AGPL-3', 'category': 'Generic Modules', 'depends': [ - 'base_attachment_queue', + 'attachment_queue', 'storage_backend', ], 'data': [ @@ -22,7 +22,6 @@ 'security/ir.model.access.csv', ], 'demo': [ - # 'demo/task_demo.xml', ], 'installable': True, 'application': False, diff --git a/attachment_synchronize/demo/task_demo.xml b/attachment_synchronize/demo/task_demo.xml deleted file mode 100644 index e6954c94370..00000000000 --- a/attachment_synchronize/demo/task_demo.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - - - TEST FTP - ftp - my-ftp-address - my-ftp-user - my-ftp-password - 21 - - - TEST SFTP - sftp - my-sftp-address - my-sftp-user - my-sftp-password - 22 - - - TEST File Store - file_store - / - - - - import - - test-import-ftp.txt - /home/user/test - Import FTP Task - - - - export - - /home/user/test - Export FTP Task - - - - import - - test-import-sftp.txt - /home/user/test - Import SFTP Task - - - - export - - /home/user/test - Export SFTP Task - - - - import - - test-import-filestore.txt - /home/user/test - Import filestore Task - - - - export - - /home/user/test - Export filestore Task - - - - Sftp text export file - dGVzdCBzZnRwIGZpbGUgZXhwb3J0 - sftp_test_export.txt - - export_external_location - - - - ftp text export file - dGVzdCBmdHAgZmlsZSBleHBvcnQ= - ftp_test_export.txt - - export_external_location - - - - filestore text export file - dGVzdCBmaWxlc3RvcmUgZmlsZSBleHBvcnQ= - filestore_test_export.txt - - export_external_location - - - diff --git a/attachment_synchronize/models/attachment.py b/attachment_synchronize/models/attachment.py index 926b5a6e6ab..28df51bb559 100644 --- a/attachment_synchronize/models/attachment.py +++ b/attachment_synchronize/models/attachment.py @@ -5,8 +5,8 @@ import os -class IrAttachmentMetadata(models.Model): - _inherit = 'ir.attachment.metadata' +class AttachmentQueue(models.Model): + _inherit = 'attachment.queue' task_id = fields.Many2one('storage.backend.task', string='Task') storage_backend_id = fields.Many2one( diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 4641a1b1d77..eafd110ce4d 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -41,7 +41,7 @@ _logger.warning("jinja2 not available, templating features will not work!") -class StorageTask(models.Model): +class StorageBackendTask(models.Model): _name = 'storage.backend.task' _description = 'Storage Backend task' @@ -56,7 +56,7 @@ class StorageTask(models.Model): filepath = fields.Char(help='Path to imported/exported file') backend_id = fields.Many2one( 'storage.backend', string='Backend', required=True) - attachment_ids = fields.One2many('ir.attachment.metadata', 'task_id', + attachment_ids = fields.One2many('attachment.queue', 'task_id', string='Attachment') move_path = fields.Char(string='Move Path', help='Imported File will be moved to this path') @@ -118,18 +118,20 @@ def _template_render(self, template, record): return render_result @api.model - def run_task_scheduler(self, domain=list()): - if ('method_type', '=', 'import') not in domain: - domain.append([('method_type', '=', 'import')]) - domain.append([('enabled', '=', True)]) - tasks = self.env['storage.backend'].search(domain) - for task in tasks: + def run_task_scheduler(self, domain=None): + if domain is None: + domain = [] + domain = expression.AND(domain, [ + ('method_type', '=', 'import'), + ('enabled', '=', True), + ]) + for task in self.search(domain): task.run_import() @api.multi def run_import(self): self.ensure_one() - attach_obj = self.env['ir.attachment.metadata'] + attach_obj = self.env['attachment.queue'] backend = self.backend_id filenames = backend._list( relative_path=self.filepath, pattern=self.pattern) diff --git a/attachment_synchronize/tests/__init__.py b/attachment_synchronize/tests/__init__.py index bbfd6ecb329..e69de29bb2d 100644 --- a/attachment_synchronize/tests/__init__.py +++ b/attachment_synchronize/tests/__init__.py @@ -1,4 +0,0 @@ -from . import mock_server -from . import test_ftp -from . import test_sftp -from . import test_filestore diff --git a/attachment_synchronize/tests/common.py b/attachment_synchronize/tests/common.py deleted file mode 100644 index b04c901acbb..00000000000 --- a/attachment_synchronize/tests/common.py +++ /dev/null @@ -1,32 +0,0 @@ -# coding: utf-8 -# @ 2016 Florian da Costa @ Akretion -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import openerp.tests.common as common -from openerp import api -from StringIO import StringIO - - -class ContextualStringIO(StringIO): - """ - snippet from http://bit.ly/1HfH6uW (stackoverflow) - """ - - def __enter__(self): - return self - - def __exit__(self, *args): - self.close() - return False - - -class TestConnection(common.TransactionCase): - - def setUp(self): - super(TestConnection, self).setUp() - self.registry.enter_test_mode() - self.env = api.Environment(self.registry.test_cr, self.env.uid, - self.env.context) - - def tearDown(self): - self.registry.leave_test_mode() - super(TestConnection, self).tearDown() diff --git a/attachment_synchronize/tests/mock_server.py b/attachment_synchronize/tests/mock_server.py deleted file mode 100644 index b024cf60c65..00000000000 --- a/attachment_synchronize/tests/mock_server.py +++ /dev/null @@ -1,74 +0,0 @@ -# coding: utf-8 -# Copyright (C) 2014 initOS GmbH & Co. KG (). -# @ 2015 Valentin CHEMIERE @ Akretion -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -import mock -from contextlib import contextmanager -from collections import defaultdict - - -class MultiResponse(dict): - pass - - -class ConnMock(object): - - def __init__(self, response): - self.response = response - self._calls = [] - self.call_count = defaultdict(int) - - def __getattribute__(self, method): - if method not in ('_calls', 'response', 'call_count'): - def callable(*args, **kwargs): - self._calls.append({ - 'method': method, - 'args': args, - 'kwargs': kwargs, - }) - call = self.response[method] - if isinstance(call, MultiResponse): - call = call[self.call_count[method]] - self.call_count[method] += 1 - return call - - return callable - else: - return super(ConnMock, self).__getattribute__(method) - - def __call__(self, *args, **kwargs): - return self - - def __enter__(self, *args, **kwargs): - return self - - def __exit__(self, *args, **kwargs): - pass - - def __repr__(self, *args, **kwargs): - return self - - def __getitem__(self, key): - return - - -@contextmanager -def server_mock_sftp(response): - with mock.patch('openerp.addons.external_file_location.tasks.sftp.' - 'SftpTask', ConnMock(response)) as SFTPFS: - yield SFTPFS._calls - - -@contextmanager -def server_mock_ftp(response): - with mock.patch('openerp.addons.external_file_location.tasks.ftp.' - 'FtpTask', ConnMock(response)) as FTPFS: - yield FTPFS._calls - - -@contextmanager -def server_mock_filestore(response): - with mock.patch('openerp.addons.external_file_location.tasks.filestore.' - 'FileStoreTask', ConnMock(response)) as FTPFS: - yield FTPFS._calls diff --git a/attachment_synchronize/tests/test_filestore.py b/attachment_synchronize/tests/test_filestore.py deleted file mode 100644 index 9091cee42f2..00000000000 --- a/attachment_synchronize/tests/test_filestore.py +++ /dev/null @@ -1,50 +0,0 @@ -# coding: utf-8 -# @ 2015 Valentin CHEMIERE @ Akretion -# ©2016 @author Mourad EL HADJ MIMOUNE -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import logging -from base64 import b64decode -from .common import TestConnection, ContextualStringIO -from .mock_server import server_mock_filestore - - -_logger = logging.getLogger(__name__) - - -class TestfilestoreConnection(TestConnection): - - def setUp(self): - super(TestfilestoreConnection, self).setUp() - self.test_file_filestore = ContextualStringIO() - self.test_file_filestore.write('import filestore') - self.test_file_filestore.seek(0) - - def test_00_filestore_import(self): - self.task = self.env.ref( - 'external_file_location.filestore_import_task') - with server_mock_filestore( - {'open': self.test_file_filestore, - 'listdir': ['test-import-filestore.txt']}): - self.task.run_import() - search_file = self.env['ir.attachment.metadata'].search( - [('name', '=', 'test-import-filestore.txt')]) - self.assertEqual(len(search_file), 1) - self.assertEqual(b64decode(search_file[0].datas), 'import filestore') - - def test_01_filestore_export(self): - self.task = self.env.ref( - 'external_file_location.filestore_export_task') - self.filestore_attachment = self.env.ref( - 'external_file_location.ir_attachment_export_file_filestore') - with server_mock_filestore( - {'setcontents': ''}) as Fakefilestore: - self.task.run_export() - if Fakefilestore: - self.assertEqual('setcontents', Fakefilestore[-1]['method']) - self.assertEqual('done', self.filestore_attachment.state) - self.assertEqual( - '/home/user/test/filestore_test_export.txt', - Fakefilestore[-1]['args'][0]) - self.assertEqual( - 'test filestore file export', - Fakefilestore[-1]['kwargs']['data']) diff --git a/attachment_synchronize/tests/test_ftp.py b/attachment_synchronize/tests/test_ftp.py deleted file mode 100644 index c5ce603b723..00000000000 --- a/attachment_synchronize/tests/test_ftp.py +++ /dev/null @@ -1,86 +0,0 @@ -# coding: utf-8 -# @ 2015 Valentin CHEMIERE @ Akretion -# ©2016 @author Mourad EL HADJ MIMOUNE -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import logging -from base64 import b64decode -import hashlib -from .common import TestConnection, ContextualStringIO -from .mock_server import server_mock_ftp -from .mock_server import MultiResponse -from openerp.exceptions import UserError - - -_logger = logging.getLogger(__name__) - - -class TestFtpConnection(TestConnection): - - def setUp(self): - super(TestFtpConnection, self).setUp() - self.test_file_ftp = ContextualStringIO() - self.test_file_ftp.write('import ftp') - self.test_file_ftp.seek(0) - - def test_00_ftp_import(self): - self.task = self.env.ref('external_file_location.ftp_import_task') - with server_mock_ftp( - {'open': self.test_file_ftp, - 'listdir': ['test-import-ftp.txt']}): - self.task.run_import() - search_file = self.env['ir.attachment.metadata'].search( - [('name', '=', 'test-import-ftp.txt')]) - self.assertEqual(len(search_file), 1) - self.assertEqual(b64decode(search_file[0].datas), 'import ftp') - - def test_01_ftp_export(self): - self.task = self.env.ref('external_file_location.ftp_export_task') - self.ftp_attachment = self.env.ref( - 'external_file_location.ir_attachment_export_file_ftp') - with server_mock_ftp( - {'setcontents': ''}) as FakeFTP: - self.task.run_export() - if FakeFTP: - self.assertEqual('setcontents', FakeFTP[-1]['method']) - self.assertEqual('done', self.ftp_attachment.state) - self.assertEqual( - '/home/user/test/ftp_test_export.txt', - FakeFTP[-1]['args'][0]) - self.assertEqual( - 'test ftp file export', - FakeFTP[-1]['kwargs']['data']) - - def test_02_ftp_import_md5(self): - md5_file = ContextualStringIO() - md5_file.write(hashlib.md5('import ftp').hexdigest()) - md5_file.seek(0) - task = self.env.ref('external_file_location.ftp_import_task') - task.md5_check = True - with server_mock_ftp( - {'open': MultiResponse({ - 1: md5_file, - 0: self.test_file_ftp}), - 'listdir': [task.filename]}) as Fakeftp: - task.run_import() - search_file = self.env['ir.attachment.metadata'].search( - (('name', '=', task.filename),)) - self.assertEqual(len(search_file), 1) - self.assertEqual(b64decode(search_file[0].datas), - 'import ftp') - self.assertEqual('open', Fakeftp[-1]['method']) - self.assertEqual(hashlib.md5('import ftp').hexdigest(), - search_file.external_hash) - - def test_03_ftp_import_md5_corrupt_file(self): - md5_file = ContextualStringIO() - md5_file.write(hashlib.md5('import test ftp corrupted').hexdigest()) - md5_file.seek(0) - task = self.env.ref('external_file_location.ftp_import_task') - task.md5_check = True - with server_mock_ftp( - {'open': MultiResponse({ - 1: md5_file, - 0: self.test_file_ftp}), - 'listdir': [task.filename]}): - with self.assertRaises(UserError): - task.run_import() diff --git a/attachment_synchronize/tests/test_sftp.py b/attachment_synchronize/tests/test_sftp.py deleted file mode 100644 index e3f128db022..00000000000 --- a/attachment_synchronize/tests/test_sftp.py +++ /dev/null @@ -1,85 +0,0 @@ -# coding: utf-8 -# @ 2015 Valentin CHEMIERE @ Akretion -# ©2016 @author Mourad EL HADJ MIMOUNE -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import logging -from base64 import b64decode -import hashlib -from .common import TestConnection, ContextualStringIO -from .mock_server import server_mock_sftp -from .mock_server import MultiResponse -from openerp.exceptions import UserError - - -_logger = logging.getLogger(__name__) - - -class TestSftpConnection(TestConnection): - - def setUp(self): - super(TestSftpConnection, self).setUp() - self.test_file_sftp = ContextualStringIO() - self.test_file_sftp.write('import sftp') - self.test_file_sftp.seek(0) - - def test_00_sftp_import(self): - task = self.env.ref('external_file_location.sftp_import_task') - with server_mock_sftp( - {'open': self.test_file_sftp, - 'listdir': [task.filename]}): - task.run_import() - search_file = self.env['ir.attachment.metadata'].search( - [('name', '=', task.filename)]) - self.assertEqual(len(search_file), 1) - self.assertEqual(b64decode(search_file[0].datas), 'import sftp') - - def test_01_sftp_export(self): - self.task = self.env.ref('external_file_location.sftp_export_task') - self.sftp_attachment = self.env.ref( - 'external_file_location.ir_attachment_export_file_sftp') - with server_mock_sftp( - {'setcontents': ''}) as FakeSFTP: - self.task.run_export() - if FakeSFTP: - self.assertEqual('setcontents', FakeSFTP[-1]['method']) - self.assertEqual( - '/home/user/test/sftp_test_export.txt', - FakeSFTP[-1]['args'][0]) - self.assertEqual( - 'test sftp file export', - FakeSFTP[-1]['kwargs']['data']) - - def test_02_sftp_import_md5(self): - md5_file = ContextualStringIO() - md5_file.write(hashlib.md5('import sftp').hexdigest()) - md5_file.seek(0) - task = self.env.ref('external_file_location.sftp_import_task') - task.md5_check = True - with server_mock_sftp( - {'open': MultiResponse({ - 1: md5_file, - 0: self.test_file_sftp}), - 'listdir': [task.filename]}) as FakeSFTP: - task.run_import() - search_file = self.env['ir.attachment.metadata'].search( - (('name', '=', task.filename),)) - self.assertEqual(len(search_file), 1) - self.assertEqual(b64decode(search_file[0].datas), - 'import sftp') - self.assertEqual('open', FakeSFTP[-1]['method']) - self.assertEqual(hashlib.md5('import sftp').hexdigest(), - search_file.external_hash) - - def test_03_sftp_import_md5_corrupt_file(self): - md5_file = ContextualStringIO() - md5_file.write(hashlib.md5('import test sftp corrupted').hexdigest()) - md5_file.seek(0) - task = self.env.ref('external_file_location.sftp_import_task') - task.md5_check = True - with server_mock_sftp( - {'open': MultiResponse({ - 1: md5_file, - 0: self.test_file_sftp}), - 'listdir': [task.filename]}): - with self.assertRaises(UserError): - task.run_import() diff --git a/attachment_synchronize/views/attachment_view.xml b/attachment_synchronize/views/attachment_view.xml index 6e4c91766d6..49dc409856c 100644 --- a/attachment_synchronize/views/attachment_view.xml +++ b/attachment_synchronize/views/attachment_view.xml @@ -1,9 +1,9 @@ - - ir.attachment.metadata - + + attachment.queue + @@ -12,9 +12,9 @@ - - ir.attachment.metadata - + + attachment.queue + From da74f9a7a50c67e58c76e39d018198cf73bf9c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 11 May 2020 21:34:13 +0200 Subject: [PATCH 10/47] [REF] refactor code and add test --- attachment_synchronize/__manifest__.py | 1 + attachment_synchronize/data/cron.xml | 8 +- .../demo/attachment_synchronize_task_demo.xml | 20 +++++ attachment_synchronize/models/attachment.py | 2 +- .../models/storage_backend.py | 4 +- attachment_synchronize/models/task.py | 23 +++--- .../security/ir.model.access.csv | 2 +- attachment_synchronize/tests/__init__.py | 2 + attachment_synchronize/tests/common.py | 38 +++++++++ attachment_synchronize/tests/test_export.py | 35 ++++++++ attachment_synchronize/tests/test_import.py | 80 +++++++++++++++++++ .../views/storage_backend_view.xml | 2 +- attachment_synchronize/views/task_view.xml | 4 +- 13 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 attachment_synchronize/demo/attachment_synchronize_task_demo.xml create mode 100644 attachment_synchronize/tests/common.py create mode 100644 attachment_synchronize/tests/test_export.py create mode 100644 attachment_synchronize/tests/test_import.py diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index 9594dbd1346..6bc0943f675 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -22,6 +22,7 @@ 'security/ir.model.access.csv', ], 'demo': [ + "demo/attachment_synchronize_task_demo.xml", ], 'installable': True, 'application': False, diff --git a/attachment_synchronize/data/cron.xml b/attachment_synchronize/data/cron.xml index ab2f883f248..57b5e766ea0 100644 --- a/attachment_synchronize/data/cron.xml +++ b/attachment_synchronize/data/cron.xml @@ -1,16 +1,16 @@ - - + + Run attachment tasks import 30 minutes -1 False - + code - model.run_task_scheduler([('method_type', '=', 'import')]) + model.run_task_import_scheduler() diff --git a/attachment_synchronize/demo/attachment_synchronize_task_demo.xml b/attachment_synchronize/demo/attachment_synchronize_task_demo.xml new file mode 100644 index 00000000000..8742b8d0621 --- /dev/null +++ b/attachment_synchronize/demo/attachment_synchronize_task_demo.xml @@ -0,0 +1,20 @@ + + + + TEST Import + + import + delete + test_import + foo@example.org,bar@example.org + + + + TEST Export + + export + test_export + foo@example.org,bar@example.org + + + diff --git a/attachment_synchronize/models/attachment.py b/attachment_synchronize/models/attachment.py index 28df51bb559..183f965a099 100644 --- a/attachment_synchronize/models/attachment.py +++ b/attachment_synchronize/models/attachment.py @@ -8,7 +8,7 @@ class AttachmentQueue(models.Model): _inherit = 'attachment.queue' - task_id = fields.Many2one('storage.backend.task', string='Task') + task_id = fields.Many2one('attachment.synchronize.task', string='Task') storage_backend_id = fields.Many2one( 'storage.backend', string='Storage Backend', related='task_id.backend_id', store=True) diff --git a/attachment_synchronize/models/storage_backend.py b/attachment_synchronize/models/storage_backend.py index b77a304db45..8d88d943519 100644 --- a/attachment_synchronize/models/storage_backend.py +++ b/attachment_synchronize/models/storage_backend.py @@ -6,6 +6,6 @@ class StorageBackend(models.Model): _inherit = "storage.backend" - task_ids = fields.One2many( - "storage.backend.task", "backend_id", + synchronize_task_ids = fields.One2many( + "attachment.synchronize.task", "backend_id", string="Tasks") diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index eafd110ce4d..4212f0cfa65 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -6,6 +6,7 @@ import odoo from odoo import api, fields, models, tools +from odoo.osv import expression _logger = logging.getLogger(__name__) @@ -41,9 +42,9 @@ _logger.warning("jinja2 not available, templating features will not work!") -class StorageBackendTask(models.Model): - _name = 'storage.backend.task' - _description = 'Storage Backend task' +class AttachmentSynchronizeTask(models.Model): + _name = 'attachment.synchronize.task' + _description = 'Attachment synchronize task' name = fields.Char(required=True) method_type = fields.Selection( @@ -118,13 +119,13 @@ def _template_render(self, template, record): return render_result @api.model - def run_task_scheduler(self, domain=None): + def run_task_import_scheduler(self, domain=None): if domain is None: domain = [] - domain = expression.AND(domain, [ + domain = expression.AND([domain, [ ('method_type', '=', 'import'), ('enabled', '=', True), - ]) + ]]) for task in self.search(domain): task.run_import() @@ -133,8 +134,8 @@ def run_import(self): self.ensure_one() attach_obj = self.env['attachment.queue'] backend = self.backend_id - filenames = backend._list( - relative_path=self.filepath, pattern=self.pattern) + filepath = self.filepath or "" + filenames = backend._list(relative_path=filepath, pattern=self.pattern) if self.check_duplicated_files: filenames = self._file_to_import(filenames) total_import = 0 @@ -145,8 +146,7 @@ def run_import(self): new_env = api.Environment(new_cr, self.env.uid, self.env.context) try: - full_absolute_path = os.path.join( - self.filepath, file_name) + full_absolute_path = os.path.join(filepath, file_name) datas = backend._get_b64_data(full_absolute_path) attach_vals = self._prepare_attachment_vals( datas, file_name) @@ -156,8 +156,7 @@ def run_import(self): if self.after_import == 'rename': new_name = self._template_render( self.new_name, attachment) - new_full_path = os.path.join( - self.filepath, new_name) + new_full_path = os.path.join(filepath, new_name) elif self.after_import == 'move': new_full_path = os.path.join( self.move_path, file_name) diff --git a/attachment_synchronize/security/ir.model.access.csv b/attachment_synchronize/security/ir.model.access.csv index 5d4973cecb1..742f94e2f4e 100644 --- a/attachment_synchronize/security/ir.model.access.csv +++ b/attachment_synchronize/security/ir.model.access.csv @@ -1,2 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_storage_backend_task_manager,storage.backend.task.manager,model_storage_backend_task,base.group_system,1,1,1,1 +access_attachment_synchronize_task_manager,attachment.synchronize.task.manager,model_attachment_synchronize_task,base.group_system,1,1,1,1 diff --git a/attachment_synchronize/tests/__init__.py b/attachment_synchronize/tests/__init__.py index e69de29bb2d..3845a51aec1 100644 --- a/attachment_synchronize/tests/__init__.py +++ b/attachment_synchronize/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_import +from . import test_export diff --git a/attachment_synchronize/tests/common.py b/attachment_synchronize/tests/common.py new file mode 100644 index 00000000000..aee8d23f36c --- /dev/null +++ b/attachment_synchronize/tests/common.py @@ -0,0 +1,38 @@ +# Copyright 2020 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import mock +import os +from odoo.addons.storage_backend.tests.common import Common + + +class SyncCommon(Common): + + def _clean_testing_directory(self): + for test_dir in [ + self.directory_input, self.directory_output, self.directory_archived]: + for filename in self.backend._list(test_dir): + self.backend._delete(os.path.join(test_dir, filename)) + + def _create_test_file(self): + self.backend._add_b64_data( + os.path.join(self.directory_input, "bar.txt"), + self.filedata, + mimetype=u"text/plain") + + def setUp(self): + super().setUp() + self.env.cr.commit = mock.Mock() + self.registry.enter_test_mode(self.env.cr) + self.directory_input = "test_import" + self.directory_output = "test_output" + self.directory_archived = "test_archived" + self._clean_testing_directory() + self._create_test_file() + self.task = self.env.ref("attachment_synchronize.import_from_filestore") + + def tearDown(self): + self.registry.leave_test_mode() + self._clean_testing_directory() + super().tearDown() diff --git a/attachment_synchronize/tests/test_export.py b/attachment_synchronize/tests/test_export.py new file mode 100644 index 00000000000..b902bc18825 --- /dev/null +++ b/attachment_synchronize/tests/test_export.py @@ -0,0 +1,35 @@ +# Copyright 2020 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os +import mock +from .common import SyncCommon + +def raising_side_effect(*args, **kwargs): + raise Exception("Boom") + + +class TestExport(SyncCommon): + + def setUp(self): + super().setUp() + self.task = self.env.ref("attachment_synchronize.export_to_filestore") + self.attachment = self.env["attachment.queue"].create({ + "name": "foo.txt", + "datas_fname": "foo.txt", + "task_id": self.task.id, + "file_type": "export", + "datas": self.filedata, + }) + + def test_export(self): + self.attachment.run() + result = self.backend._list("test_export") + self.assertEqual(result, ["foo.txt"]) + + def test_failing_export(self): + with mock.patch.object(type(self.backend), "_add_b64_data", side_effect=raising_side_effect): + self.attachment.run() + self.assertEqual(self.attachment.state, "failed") + self.assertEqual(self.attachment.state_message, "Boom") diff --git a/attachment_synchronize/tests/test_import.py b/attachment_synchronize/tests/test_import.py new file mode 100644 index 00000000000..d1c58d5e598 --- /dev/null +++ b/attachment_synchronize/tests/test_import.py @@ -0,0 +1,80 @@ +# Copyright 2020 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common import SyncCommon + + +class TestImport(SyncCommon): + + @property + def archived_files(self): + return self.backend._list(self.directory_archived) + + @property + def input_files(self): + return self.backend._list(self.directory_input) + + def _check_attachment_created(self, count=1): + attachment = self.env["attachment.queue"].search([("name", "=", "bar.txt")]) + self.assertEqual(len(attachment), count) + + def test_import_with_rename(self): + self.task.write({"after_import": "rename", "new_name": "foo.txt"}) + self.task.run_import() + self._check_attachment_created() + self.assertEqual(self.input_files, ["foo.txt"]) + self.assertEqual(self.archived_files, []) + + def test_import_with_move(self): + self.task.write({"after_import": "move", "move_path": self.directory_archived}) + self.task.run_import() + self._check_attachment_created() + self.assertEqual(self.input_files, []) + self.assertEqual(self.archived_files, ["bar.txt"]) + + def test_import_with_move_and_rename(self): + self.task.write({ + "after_import": "move_rename", + "new_name": "foo.txt", + "move_path": self.directory_archived, + }) + self.task.run_import() + self._check_attachment_created() + self.assertEqual(self.input_files, []) + self.assertEqual(self.archived_files, ["foo.txt"]) + + def test_import_with_delete(self): + self.task.write({"after_import": "delete"}) + self.task.run_import() + self._check_attachment_created() + self.assertEqual(self.input_files, []) + self.assertEqual(self.archived_files, []) + + def test_import_twice(self): + self.task.write({"after_import": "delete"}) + self.task.run_import() + self._check_attachment_created(count=1) + + self._create_test_file() + self.task.run_import() + self._check_attachment_created(count=2) + + def test_import_twice_no_duplicate(self): + self.task.write({"after_import": "delete", "check_duplicated_files": True}) + self.task.run_import() + self._check_attachment_created(count=1) + + self._create_test_file() + self.task.run_import() + self._check_attachment_created(count=1) + + def test_running_cron(self): + self.task.write({"after_import": "delete"}) + self.env["attachment.synchronize.task"].run_task_import_scheduler() + self._check_attachment_created(count=1) + + def test_running_cron_disable_task(self): + self.task.write({"after_import": "delete", "enabled": False}) + self.env["attachment.synchronize.task"].run_task_import_scheduler() + self._check_attachment_created(count=0) diff --git a/attachment_synchronize/views/storage_backend_view.xml b/attachment_synchronize/views/storage_backend_view.xml index 143aa1b467f..71d159fdf30 100644 --- a/attachment_synchronize/views/storage_backend_view.xml +++ b/attachment_synchronize/views/storage_backend_view.xml @@ -9,7 +9,7 @@ - + diff --git a/attachment_synchronize/views/task_view.xml b/attachment_synchronize/views/task_view.xml index 9195143aa44..75b8a83214c 100644 --- a/attachment_synchronize/views/task_view.xml +++ b/attachment_synchronize/views/task_view.xml @@ -1,7 +1,7 @@ - storage.backend.task + attachment.synchronize.task
@@ -37,7 +37,7 @@ - storage.backend.task + attachment.synchronize.task From c616a4a6eec5384c97b617870a385493b34b0c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 11 May 2020 21:51:34 +0200 Subject: [PATCH 11/47] [REF] run black --- attachment_synchronize/__manifest__.py | 37 ++-- attachment_synchronize/models/attachment.py | 21 ++- .../models/storage_backend.py | 4 +- attachment_synchronize/models/task.py | 172 ++++++++++-------- attachment_synchronize/tests/common.py | 13 +- attachment_synchronize/tests/test_export.py | 25 ++- attachment_synchronize/tests/test_import.py | 25 ++- 7 files changed, 168 insertions(+), 129 deletions(-) diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index 6bc0943f675..68aff2a43f3 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -4,26 +4,21 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { - 'name': 'Attachment Synchronize', - 'version': '12.0.1.0.0', - 'author': 'Akretion,Odoo Community Association (OCA)', - 'website': 'https://github.com/oca/server-tools', - 'license': 'AGPL-3', - 'category': 'Generic Modules', - 'depends': [ - 'attachment_queue', - 'storage_backend', + "name": "Attachment Synchronize", + "version": "12.0.1.0.0", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/oca/server-tools", + "license": "AGPL-3", + "category": "Generic Modules", + "depends": ["attachment_queue", "storage_backend"], + "data": [ + "views/attachment_view.xml", + "views/task_view.xml", + "views/storage_backend_view.xml", + "data/cron.xml", + "security/ir.model.access.csv", ], - 'data': [ - 'views/attachment_view.xml', - 'views/task_view.xml', - 'views/storage_backend_view.xml', - 'data/cron.xml', - 'security/ir.model.access.csv', - ], - 'demo': [ - "demo/attachment_synchronize_task_demo.xml", - ], - 'installable': True, - 'application': False, + "demo": ["demo/attachment_synchronize_task_demo.xml"], + "installable": True, + "application": False, } diff --git a/attachment_synchronize/models/attachment.py b/attachment_synchronize/models/attachment.py index 183f965a099..7a8e3390dea 100644 --- a/attachment_synchronize/models/attachment.py +++ b/attachment_synchronize/models/attachment.py @@ -1,26 +1,27 @@ # @ 2016 Florian DA COSTA @ Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import models, fields, api import os +from odoo import models, fields class AttachmentQueue(models.Model): - _inherit = 'attachment.queue' + _inherit = "attachment.queue" - task_id = fields.Many2one('attachment.synchronize.task', string='Task') + task_id = fields.Many2one("attachment.synchronize.task", string="Task") storage_backend_id = fields.Many2one( - 'storage.backend', string='Storage Backend', - related='task_id.backend_id', store=True) + "storage.backend", + string="Storage Backend", + related="task_id.backend_id", + store=True, + ) file_type = fields.Selection( - selection_add=[ - ('export', - 'Export File (External location)') - ]) + selection_add=[("export", "Export File (External location)")] + ) def _run(self): super()._run() - if self.file_type == 'export': + if self.file_type == "export": path = os.path.join(self.task_id.filepath, self.datas_fname) self.storage_backend_id._add_b64_data(path, self.datas) diff --git a/attachment_synchronize/models/storage_backend.py b/attachment_synchronize/models/storage_backend.py index 8d88d943519..a5519ba456a 100644 --- a/attachment_synchronize/models/storage_backend.py +++ b/attachment_synchronize/models/storage_backend.py @@ -7,5 +7,5 @@ class StorageBackend(models.Model): _inherit = "storage.backend" synchronize_task_ids = fields.One2many( - "attachment.synchronize.task", "backend_id", - string="Tasks") + "attachment.synchronize.task", "backend_id", string="Tasks" + ) diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 4212f0cfa65..81999f79e1e 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -20,82 +20,96 @@ # This is done on purpose: it prevents incidental or malicious execution of # Python code that may break the security of the server. from jinja2.sandbox import SandboxedEnvironment + mako_template_env = SandboxedEnvironment( variable_start_string="${", variable_end_string="}", line_statement_prefix="%", - trim_blocks=True, # do not output newline after blocks + trim_blocks=True, # do not output newline after blocks + ) + mako_template_env.globals.update( + { + "str": str, + "datetime": datetime, + "len": len, + "abs": abs, + "min": min, + "max": max, + "sum": sum, + "filter": filter, + "map": map, + "round": round, + } ) - mako_template_env.globals.update({ - 'str': str, - 'datetime': datetime, - 'len': len, - 'abs': abs, - 'min': min, - 'max': max, - 'sum': sum, - 'filter': filter, - 'map': map, - 'round': round, - }) except ImportError: _logger.warning("jinja2 not available, templating features will not work!") class AttachmentSynchronizeTask(models.Model): - _name = 'attachment.synchronize.task' - _description = 'Attachment synchronize task' + _name = "attachment.synchronize.task" + _description = "Attachment synchronize task" name = fields.Char(required=True) method_type = fields.Selection( - [('import', 'Import'), ('export', 'Export')], - required=True) - pattern = fields.Char(help='File name which is imported.' - 'The system will check if the remote file at ' - 'least contains the pattern in its name. ' - 'Leave it empty to import all files') - filepath = fields.Char(help='Path to imported/exported file') + [("import", "Import"), ("export", "Export")], required=True + ) + pattern = fields.Char( + help="File name which is imported." + "The system will check if the remote file at " + "least contains the pattern in its name. " + "Leave it empty to import all files" + ) + filepath = fields.Char(help="Path to imported/exported file") backend_id = fields.Many2one( - 'storage.backend', string='Backend', required=True) - attachment_ids = fields.One2many('attachment.queue', 'task_id', - string='Attachment') - move_path = fields.Char(string='Move Path', - help='Imported File will be moved to this path') - new_name = fields.Char(string='New Name', - help='Imported File will be renamed to this name\n' - 'Name can use mako template where obj is an ' - 'ir_attachement. template exemple : ' - ' ${obj.name}-${obj.create_date}.csv') + "storage.backend", string="Backend", required=True + ) + attachment_ids = fields.One2many( + "attachment.queue", "task_id", string="Attachment" + ) + move_path = fields.Char( + string="Move Path", help="Imported File will be moved to this path" + ) + new_name = fields.Char( + string="New Name", + help="Imported File will be renamed to this name\n" + "Name can use mako template where obj is an " + "ir_attachement. template exemple : " + " ${obj.name}-${obj.create_date}.csv", + ) after_import = fields.Selection( selection=[ - ('rename', 'Rename'), - ('move', 'Move'), - ('move_rename', 'Move & Rename'), - ('delete', 'Delete'), - ], help='Action after import a file') + ("rename", "Rename"), + ("move", "Move"), + ("move_rename", "Move & Rename"), + ("delete", "Delete"), + ], + help="Action after import a file", + ) file_type = fields.Selection( selection=[], string="File Type", help="The file type determines an import method to be used " - "to parse and transform data before their import in ERP") - enabled = fields.Boolean('Enabled', default=True) + "to parse and transform data before their import in ERP", + ) + enabled = fields.Boolean("Enabled", default=True) check_duplicated_files = fields.Boolean( - string='Check duplicated files', - help='If checked, will avoid duplication file import') + string="Check duplicated files", + help="If checked, will avoid duplication file import", + ) emails = fields.Char( string="Emails", help="list of email which should be notified in case of failure " - "when excuting the files linked to this task") + "when excuting the files linked to this task", + ) - @api.multi def _prepare_attachment_vals(self, datas, filename): self.ensure_one() vals = { - 'name': filename, - 'datas': datas, - 'datas_fname': filename, - 'task_id': self.id, - 'file_type': self.file_type or False, + "name": filename, + "datas": datas, + "datas_fname": filename, + "task_id": self.id, + "file_type": self.file_type or False, } return vals @@ -106,13 +120,14 @@ def _template_render(self, template, record): except Exception: _logger.exception("Failed to load template %r", template) - variables = {'obj': record} + variables = {"obj": record} try: render_result = template.render(variables) except Exception: _logger.exception( - "Failed to render template %r using values %r" % - (template, variables)) + "Failed to render template %r using values %r" + % (template, variables) + ) render_result = u"" if render_result == u"False": render_result = u"" @@ -122,17 +137,15 @@ def _template_render(self, template, record): def run_task_import_scheduler(self, domain=None): if domain is None: domain = [] - domain = expression.AND([domain, [ - ('method_type', '=', 'import'), - ('enabled', '=', True), - ]]) + domain = expression.AND( + [domain, [("method_type", "=", "import"), ("enabled", "=", True)]] + ) for task in self.search(domain): task.run_import() - @api.multi def run_import(self): self.ensure_one() - attach_obj = self.env['attachment.queue'] + attach_obj = self.env["attachment.queue"] backend = self.backend_id filepath = self.filepath or "" filenames = backend._list(relative_path=filepath, pattern=self.pattern) @@ -141,34 +154,43 @@ def run_import(self): total_import = 0 for file_name in filenames: with api.Environment.manage(): - with odoo.registry( - self.env.cr.dbname).cursor() as new_cr: - new_env = api.Environment(new_cr, self.env.uid, - self.env.context) + with odoo.registry(self.env.cr.dbname).cursor() as new_cr: + new_env = api.Environment( + new_cr, self.env.uid, self.env.context + ) try: full_absolute_path = os.path.join(filepath, file_name) datas = backend._get_b64_data(full_absolute_path) attach_vals = self._prepare_attachment_vals( - datas, file_name) + datas, file_name + ) attachment = attach_obj.with_env(new_env).create( - attach_vals) + attach_vals + ) new_full_path = False - if self.after_import == 'rename': + if self.after_import == "rename": new_name = self._template_render( - self.new_name, attachment) + self.new_name, attachment + ) new_full_path = os.path.join(filepath, new_name) - elif self.after_import == 'move': + elif self.after_import == "move": new_full_path = os.path.join( - self.move_path, file_name) - elif self.after_import == 'move_rename': + self.move_path, file_name + ) + elif self.after_import == "move_rename": new_name = self._template_render( - self.new_name, attachment) + self.new_name, attachment + ) new_full_path = os.path.join( - self.move_path, new_name) + self.move_path, new_name + ) if new_full_path: backend._add_b64_data(new_full_path, datas) if self.after_import in ( - 'delete', 'rename', 'move', 'move_rename' + "delete", + "rename", + "move", + "move_rename", ): backend._delete(full_absolute_path) total_import += 1 @@ -177,8 +199,12 @@ def run_import(self): raise e else: new_env.cr.commit() - _logger.info('Run import complete! Imported {0} files'.format(total_import)) + _logger.info( + "Run import complete! Imported {0} files".format(total_import) + ) def _file_to_import(self, filenames): - imported = self.attachment_ids.filtered(lambda r: r.name in filenames).mapped('name') + imported = self.attachment_ids.filtered( + lambda r: r.name in filenames + ).mapped("name") return list(set(filenames) - set(imported)) diff --git a/attachment_synchronize/tests/common.py b/attachment_synchronize/tests/common.py index aee8d23f36c..56418e556bd 100644 --- a/attachment_synchronize/tests/common.py +++ b/attachment_synchronize/tests/common.py @@ -8,10 +8,12 @@ class SyncCommon(Common): - def _clean_testing_directory(self): for test_dir in [ - self.directory_input, self.directory_output, self.directory_archived]: + self.directory_input, + self.directory_output, + self.directory_archived, + ]: for filename in self.backend._list(test_dir): self.backend._delete(os.path.join(test_dir, filename)) @@ -19,7 +21,8 @@ def _create_test_file(self): self.backend._add_b64_data( os.path.join(self.directory_input, "bar.txt"), self.filedata, - mimetype=u"text/plain") + mimetype=u"text/plain", + ) def setUp(self): super().setUp() @@ -30,7 +33,9 @@ def setUp(self): self.directory_archived = "test_archived" self._clean_testing_directory() self._create_test_file() - self.task = self.env.ref("attachment_synchronize.import_from_filestore") + self.task = self.env.ref( + "attachment_synchronize.import_from_filestore" + ) def tearDown(self): self.registry.leave_test_mode() diff --git a/attachment_synchronize/tests/test_export.py b/attachment_synchronize/tests/test_export.py index b902bc18825..1f37c41ef7c 100644 --- a/attachment_synchronize/tests/test_export.py +++ b/attachment_synchronize/tests/test_export.py @@ -2,26 +2,27 @@ # @author Sébastien BEAU # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import os import mock from .common import SyncCommon + def raising_side_effect(*args, **kwargs): raise Exception("Boom") class TestExport(SyncCommon): - def setUp(self): super().setUp() self.task = self.env.ref("attachment_synchronize.export_to_filestore") - self.attachment = self.env["attachment.queue"].create({ - "name": "foo.txt", - "datas_fname": "foo.txt", - "task_id": self.task.id, - "file_type": "export", - "datas": self.filedata, - }) + self.attachment = self.env["attachment.queue"].create( + { + "name": "foo.txt", + "datas_fname": "foo.txt", + "task_id": self.task.id, + "file_type": "export", + "datas": self.filedata, + } + ) def test_export(self): self.attachment.run() @@ -29,7 +30,11 @@ def test_export(self): self.assertEqual(result, ["foo.txt"]) def test_failing_export(self): - with mock.patch.object(type(self.backend), "_add_b64_data", side_effect=raising_side_effect): + with mock.patch.object( + type(self.backend), + "_add_b64_data", + side_effect=raising_side_effect, + ): self.attachment.run() self.assertEqual(self.attachment.state, "failed") self.assertEqual(self.attachment.state_message, "Boom") diff --git a/attachment_synchronize/tests/test_import.py b/attachment_synchronize/tests/test_import.py index d1c58d5e598..8846ff26ee5 100644 --- a/attachment_synchronize/tests/test_import.py +++ b/attachment_synchronize/tests/test_import.py @@ -6,7 +6,6 @@ class TestImport(SyncCommon): - @property def archived_files(self): return self.backend._list(self.directory_archived) @@ -16,7 +15,9 @@ def input_files(self): return self.backend._list(self.directory_input) def _check_attachment_created(self, count=1): - attachment = self.env["attachment.queue"].search([("name", "=", "bar.txt")]) + attachment = self.env["attachment.queue"].search( + [("name", "=", "bar.txt")] + ) self.assertEqual(len(attachment), count) def test_import_with_rename(self): @@ -27,18 +28,22 @@ def test_import_with_rename(self): self.assertEqual(self.archived_files, []) def test_import_with_move(self): - self.task.write({"after_import": "move", "move_path": self.directory_archived}) + self.task.write( + {"after_import": "move", "move_path": self.directory_archived} + ) self.task.run_import() self._check_attachment_created() self.assertEqual(self.input_files, []) self.assertEqual(self.archived_files, ["bar.txt"]) def test_import_with_move_and_rename(self): - self.task.write({ - "after_import": "move_rename", - "new_name": "foo.txt", - "move_path": self.directory_archived, - }) + self.task.write( + { + "after_import": "move_rename", + "new_name": "foo.txt", + "move_path": self.directory_archived, + } + ) self.task.run_import() self._check_attachment_created() self.assertEqual(self.input_files, []) @@ -61,7 +66,9 @@ def test_import_twice(self): self._check_attachment_created(count=2) def test_import_twice_no_duplicate(self): - self.task.write({"after_import": "delete", "check_duplicated_files": True}) + self.task.write( + {"after_import": "delete", "check_duplicated_files": True} + ) self.task.run_import() self._check_attachment_created(count=1) From f597b71ddb804221d76f036253c84ce706b36920 Mon Sep 17 00:00:00 2001 From: David Beal Date: Wed, 3 Jun 2020 19:27:30 +0200 Subject: [PATCH 12/47] IMP add button to duplicate task or unactive them, add readme fragments --- attachment_synchronize/README.rst | 109 +++-- attachment_synchronize/__manifest__.py | 3 +- attachment_synchronize/models/task.py | 25 +- attachment_synchronize/readme/CONFIGURE.rst | 15 + .../readme/CONTRIBUTORS.rst | 9 + attachment_synchronize/readme/DESCRIPTION.rst | 6 + .../static/description/file.png | Bin 0 -> 41674 bytes .../static/description/index.html | 443 ++++++++++++++++++ .../static/description/sftp.png | Bin 0 -> 32609 bytes attachment_synchronize/views/task_view.xml | 7 +- 10 files changed, 572 insertions(+), 45 deletions(-) create mode 100644 attachment_synchronize/readme/CONFIGURE.rst create mode 100644 attachment_synchronize/readme/CONTRIBUTORS.rst create mode 100644 attachment_synchronize/readme/DESCRIPTION.rst create mode 100644 attachment_synchronize/static/description/file.png create mode 100644 attachment_synchronize/static/description/index.html create mode 100644 attachment_synchronize/static/description/sftp.png diff --git a/attachment_synchronize/README.rst b/attachment_synchronize/README.rst index cd377e983a3..35b73a97749 100644 --- a/attachment_synchronize/README.rst +++ b/attachment_synchronize/README.rst @@ -1,24 +1,38 @@ -.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 - ====================== Attachment Synchronize ====================== -This module was written to extend the functionality of ir.attachment to support remote communication and allow you to import/export file to a remote server. -For now, FTP, SFTP and local filestore are handled by the module. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-akretion%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/akretion/server-tools/tree/12-mig-external_file_location/attachment_synchronize + :alt: akretion/server-tools + +|badge1| |badge2| |badge3| + +This module allow you to deal with remote communication to import/export files. +It allow to store paths and settings from/to remote servers. -Installation -============ +It depends of attachment_queue to store attachments -To install this module, you need to: +With additional modules coming from https://github.com/storage you can use ftp, sftp, etc -* fs python module at version 0.5.4 or under -* Paramiko python module +**Table of contents** -Usage -===== +.. contents:: + :local: + +Configuration +============= To use this module, you need to: @@ -26,45 +40,64 @@ To use this module, you need to: * Create a task with your file info and remote communication method * A cron task will trigger each task -.. 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/9.0 + +.. figure:: https://raw.githubusercontent.com/akretion/server-tools/12-mig-external_file_location/attachment_synchronize/static/description/file.png + + + +.. figure:: https://raw.githubusercontent.com/akretion/server-tools/12-mig-external_file_location/attachment_synchronize/static/description/sftp.png + + +With the help of storage_backend_sftp 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 smashing it by providing a detailed and welcomed feedback. +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. Credits ======= -Images ------- +Authors +~~~~~~~ -* Odoo Community Association: `Icon `_. +* Akretion Contributors ------------- +~~~~~~~~~~~~ + +`Akretion `_ : + +- Valentin CHEMIERE +- Mourad EL HADJ MIMOUNE +- Florian DA COSTA + +GS Lab: + +- Giovanni SERRA -* Valentin CHEMIERE -* Mourad EL HADJ MIMOUNE -* Florian DA COSTA -* Giovanni SERRA +Maintainers +~~~~~~~~~~~ -Maintainer ----------- +.. |maintainer-florian-dacosta| image:: https://github.com/florian-dacosta.png?size=40px + :target: https://github.com/florian-dacosta + :alt: florian-dacosta +.. |maintainer-GSLabIt| image:: https://github.com/GSLabIt.png?size=40px + :target: https://github.com/GSLabIt + :alt: GSLabIt +.. |maintainer-bealdav| image:: https://github.com/bealdav.png?size=40px + :target: https://github.com/bealdav + :alt: bealdav -.. image:: https://odoo-community.org/logo.png - :alt: Odoo Community Association - :target: https://odoo-community.org +Current maintainers: -This module is maintained by the OCA. +|maintainer-florian-dacosta| |maintainer-GSLabIt| |maintainer-bealdav| -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 `akretion/server-tools `_ project on GitHub. -To contribute to this module, please visit https://odoo-community.org. +You are welcome to contribute. diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index 68aff2a43f3..f1d86490b90 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -20,5 +20,6 @@ ], "demo": ["demo/attachment_synchronize_task_demo.xml"], "installable": True, - "application": False, + "development_status": "Beta", + "maintainers": ["florian-dacosta", "GSLabIt", "bealdav"], } diff --git a/attachment_synchronize/models/task.py b/attachment_synchronize/models/task.py index 81999f79e1e..5bea4d54104 100644 --- a/attachment_synchronize/models/task.py +++ b/attachment_synchronize/models/task.py @@ -102,11 +102,11 @@ class AttachmentSynchronizeTask(models.Model): "when excuting the files linked to this task", ) - def _prepare_attachment_vals(self, datas, filename): + def _prepare_attachment_vals(self, data, filename): self.ensure_one() vals = { "name": filename, - "datas": datas, + "datas": data, "datas_fname": filename, "task_id": self.id, "file_type": self.file_type or False, @@ -160,9 +160,9 @@ def run_import(self): ) try: full_absolute_path = os.path.join(filepath, file_name) - datas = backend._get_b64_data(full_absolute_path) + data = backend._get_b64_data(full_absolute_path) attach_vals = self._prepare_attachment_vals( - datas, file_name + data, file_name ) attachment = attach_obj.with_env(new_env).create( attach_vals @@ -185,7 +185,7 @@ def run_import(self): self.move_path, new_name ) if new_full_path: - backend._add_b64_data(new_full_path, datas) + backend._add_b64_data(new_full_path, data) if self.after_import in ( "delete", "rename", @@ -208,3 +208,18 @@ def _file_to_import(self, filenames): lambda r: r.name in filenames ).mapped("name") return list(set(filenames) - set(imported)) + + def button_toogle_enabled(self): + for rec in self: + rec.enabled = not rec.enabled + + def button_duplicate_record(self): + self.ensure_one() + record = self.copy({"enabled": False}) + return { + "type": "ir.actions.act_window", + "res_model": record.backend_id._name, + "target": "current", + "view_mode": "form", + "res_id": record.backend_id.id, + } diff --git a/attachment_synchronize/readme/CONFIGURE.rst b/attachment_synchronize/readme/CONFIGURE.rst new file mode 100644 index 00000000000..12b91d76ba3 --- /dev/null +++ b/attachment_synchronize/readme/CONFIGURE.rst @@ -0,0 +1,15 @@ +To use this module, you need to: + +* Add a location with your server infos +* Create a task with your file info and remote communication method +* A cron task will trigger each task + + +.. figure:: ../static/description/file.png + + + +.. figure:: ../static/description/sftp.png + + +With the help of storage_backend_sftp \ No newline at end of file diff --git a/attachment_synchronize/readme/CONTRIBUTORS.rst b/attachment_synchronize/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..95bbd0811fa --- /dev/null +++ b/attachment_synchronize/readme/CONTRIBUTORS.rst @@ -0,0 +1,9 @@ +`Akretion `_ : + +- Valentin CHEMIERE +- Mourad EL HADJ MIMOUNE +- Florian DA COSTA + +GS Lab: + +- Giovanni SERRA diff --git a/attachment_synchronize/readme/DESCRIPTION.rst b/attachment_synchronize/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..db1469eabec --- /dev/null +++ b/attachment_synchronize/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +This module allow you to deal with remote communication to import/export files. +It allow to store paths and settings from/to remote servers. + +It depends of attachment_queue to store attachments + +With additional modules coming from https://github.com/storage you can use ftp, sftp, etc diff --git a/attachment_synchronize/static/description/file.png b/attachment_synchronize/static/description/file.png new file mode 100644 index 0000000000000000000000000000000000000000..a8617a0afd9430c0b2534095641feffdb7ff13ba GIT binary patch literal 41674 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 RjCOLmyE?`|l$O(@{{f&28IS+~ literal 0 HcmV?d00001 diff --git a/attachment_synchronize/static/description/index.html b/attachment_synchronize/static/description/index.html new file mode 100644 index 00000000000..026575aa0e4 --- /dev/null +++ b/attachment_synchronize/static/description/index.html @@ -0,0 +1,443 @@ + + + + + + +Attachment Synchronize + + + + + + diff --git a/attachment_synchronize/static/description/sftp.png b/attachment_synchronize/static/description/sftp.png new file mode 100644 index 0000000000000000000000000000000000000000..665d4258b77dc8c9a1d9a21b67d0b1e77cb4600c GIT binary patch literal 32609 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 attachment.synchronize.task - + + + + +
+

Attachment Synchronize

+ + +

Beta License: AGPL-3 akretion/server-tools

+

This module allow you to deal with remote communication to import/export files. +It allow to store paths and settings from/to remote servers.

+

It depends of attachment_queue to store attachments

+

With additional modules coming from https://github.com/storage you can use ftp, sftp, etc

+

Table of contents

+ +
+

Configuration

+

To use this module, you need to:

+
    +
  • Add a location with your server infos
  • +
  • Create a task with your file info and remote communication method
  • +
  • A cron task will trigger each task
  • +
+
+https://raw.githubusercontent.com/akretion/server-tools/12-mig-external_file_location/attachment_synchronize/static/description/file.png +
+
+https://raw.githubusercontent.com/akretion/server-tools/12-mig-external_file_location/attachment_synchronize/static/description/sftp.png +
+

With the help of storage_backend_sftp

+
+
+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+

Akretion :

+ +

GS Lab:

+ +
+
+

Maintainers

+

Current maintainers:

+

florian-dacosta GSLabIt bealdav

+

This module is part of the akretion/server-tools project on GitHub.

+

You are welcome to contribute.

+
+
+
-
+
+
+ + + + + + + + + + + + + + + + + +
- - - - - - - - - - - - - - - - - + attachment.synchronize.task - + + +
- + + - - - +
@@ -45,7 +58,6 @@ - @@ -69,33 +81,67 @@
- - Attachments Tasks + + + + Attachments Import Tasks ir.actions.act_window attachment.synchronize.task form tree,form + [('method_type', '=', 'import')] tree - + form - + - + action="action_attachment_import_task"/> + + + + Attachments Export Tasks + ir.actions.act_window + attachment.synchronize.task + form + tree,form + + + [('method_type', '=', 'export')] + + + + + tree + + + + + + + form + + + + +
diff --git a/attachment_synchronize/views/storage_backend_view.xml b/attachment_synchronize/views/storage_backend_views.xml similarity index 100% rename from attachment_synchronize/views/storage_backend_view.xml rename to attachment_synchronize/views/storage_backend_views.xml From 81462638a67058f3754d7f6845e148e7041fc3fe Mon Sep 17 00:00:00 2001 From: clementmbr Date: Wed, 8 Jul 2020 12:59:50 -0300 Subject: [PATCH 16/47] [IMP] task run_export + onchange link method_type and file_type if export --- .../models/attachment_queue.py | 8 +++++++- .../models/attachment_synchronize_task.py | 19 +++++++++---------- .../attachment_synchronize_task_views.xml | 13 +++++++++---- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/attachment_synchronize/models/attachment_queue.py b/attachment_synchronize/models/attachment_queue.py index 7a8e3390dea..1047ee9f127 100644 --- a/attachment_synchronize/models/attachment_queue.py +++ b/attachment_synchronize/models/attachment_queue.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import os -from odoo import models, fields +from odoo import api, models, fields class AttachmentQueue(models.Model): @@ -30,3 +30,9 @@ def _get_failure_emails(self): if self.task_id.emails: res = self.task_id.emails return res + + @api.onchange("task_id") + def onchange_task_id(self): + for attachment in self: + if attachment.task_id.method_type == "export": + attachment.file_type = "export" diff --git a/attachment_synchronize/models/attachment_synchronize_task.py b/attachment_synchronize/models/attachment_synchronize_task.py index 5d92399381b..d1ad764c9d7 100644 --- a/attachment_synchronize/models/attachment_synchronize_task.py +++ b/attachment_synchronize/models/attachment_synchronize_task.py @@ -100,9 +100,11 @@ class AttachmentSynchronizeTask(models.Model): "when excuting the files linked to this task", ) - def toogle_enabled(self): + @api.onchange("method_type") + def onchange_method_type(self): for task in self: - task.enabled = not task.enabled + if task.method_type == "export": + task.file_type = "export" def _prepare_attachment_vals(self, data, filename): self.ensure_one() @@ -196,17 +198,14 @@ def _file_to_import(self, filenames): ) return list(set(filenames) - set(imported)) + def run_export(self): + for task in self: + task.attachment_ids.filtered(lambda a: a.state == "pending").run() + def button_toogle_enabled(self): for rec in self: rec.enabled = not rec.enabled def button_duplicate_record(self): self.ensure_one() - record = self.copy({"enabled": False}) - return { - "type": "ir.actions.act_window", - "res_model": record.backend_id._name, - "target": "current", - "view_mode": "form", - "res_id": record.backend_id.id, - } + self.copy({"enabled": False}) diff --git a/attachment_synchronize/views/attachment_synchronize_task_views.xml b/attachment_synchronize/views/attachment_synchronize_task_views.xml index 4ca32bbc173..13760d46b8c 100644 --- a/attachment_synchronize/views/attachment_synchronize_task_views.xml +++ b/attachment_synchronize/views/attachment_synchronize_task_views.xml @@ -7,9 +7,12 @@
+
+
-
-
+ + + + + + + + + + + + + - - - - - - - - - - + + - - - - -
- - +
- + attachment.synchronize.task @@ -70,7 +68,21 @@ + + From 76997202a4fe3f87342e2d094e4e2e32729fbc44 Mon Sep 17 00:00:00 2001 From: clementmbr Date: Thu, 9 Jul 2020 11:20:05 -0300 Subject: [PATCH 20/47] [IMP] rename task emails into failure_emails and improve tasks views --- .../models/attachment_queue.py | 4 +-- .../models/attachment_synchronize_task.py | 27 ++++++++++------- .../attachment_synchronize_task_views.xml | 29 ++++++++++--------- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/attachment_synchronize/models/attachment_queue.py b/attachment_synchronize/models/attachment_queue.py index 19c173761f6..48327c76c0f 100644 --- a/attachment_synchronize/models/attachment_queue.py +++ b/attachment_synchronize/models/attachment_queue.py @@ -28,8 +28,8 @@ def _run(self): def _get_failure_emails(self): res = super()._get_failure_emails() - if self.task_id.emails: - res = self.task_id.emails + if self.task_id.failure_emails: + res = self.task_id.failure_emails return res @api.onchange("task_id") diff --git a/attachment_synchronize/models/attachment_synchronize_task.py b/attachment_synchronize/models/attachment_synchronize_task.py index 649cd03baa6..b682a6d4cef 100644 --- a/attachment_synchronize/models/attachment_synchronize_task.py +++ b/attachment_synchronize/models/attachment_synchronize_task.py @@ -51,10 +51,11 @@ class AttachmentSynchronizeTask(models.Model): name = fields.Char(required=True) method_type = fields.Selection( - [("import", "Import"), ("export", "Export")], required=True + [("import", "Import Task"), ("export", "Export Task")], required=True ) pattern = fields.Char( - help="Used to select the files to be imported. Import all the files if empty." + string="Selection Pattern", + help="Used to select the files to be imported. If empty, import all the files.", ) filepath = fields.Char( string="File Path", help="Path to imported/exported files in the Backend" @@ -83,16 +84,20 @@ class AttachmentSynchronizeTask(models.Model): file_type = fields.Selection( selection=[], string="File Type", - help="The file type indicates what Odoo will do with the files once imported", + help="The file type allows Odoo to recognize what to do with the files " + "once imported.", ) enabled = fields.Boolean("Enabled", default=True) avoid_duplicated_files = fields.Boolean( - string="Avoid duplicated files importation", - help="If checked, will avoid duplication file import", + string="Avoid importing duplicated files", + help="If checked, a file will not be imported if there is already an " + "Attachment Queue with the same name.", ) - emails = fields.Char( - string="Notification Emails", - help="These emails will receive a notification in case of the task failure", + failure_emails = fields.Char( + string="Failure Emails", + help="Used to fill the field 'Failure Emails' in the task related " + "Attachments Queues.\nThese emails will be notified if any operation on these " + "Attachment Queue's file type fails.", ) def _prepare_attachment_vals(self, data, filename): @@ -111,14 +116,16 @@ def _template_render(self, template, record): try: template = mako_template_env.from_string(tools.ustr(template)) except Exception: - _logger.exception("Failed to load template %r", template) + _logger.exception("Failed to load template '{}'".format(template)) variables = {"obj": record} try: render_result = template.render(variables) except Exception: _logger.exception( - "Failed to render template %r using values %r" % (template, variables) + "Failed to render template '{}'' using values '{}'".format( + template, variables + ) ) render_result = u"" if render_result == u"False": diff --git a/attachment_synchronize/views/attachment_synchronize_task_views.xml b/attachment_synchronize/views/attachment_synchronize_task_views.xml index 17102715519..5d2da5bd1f9 100644 --- a/attachment_synchronize/views/attachment_synchronize_task_views.xml +++ b/attachment_synchronize/views/attachment_synchronize_task_views.xml @@ -12,7 +12,7 @@
-
-
+
- - + - + @@ -47,7 +48,7 @@ - + @@ -61,14 +62,14 @@ - + - + - + -
diff --git a/attachment_synchronize/static/description/sftp.png b/attachment_synchronize/static/description/sftp.png deleted file mode 100644 index 665d4258b77dc8c9a1d9a21b67d0b1e77cb4600c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32609 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 Date: Tue, 14 Jul 2020 15:24:54 -0300 Subject: [PATCH 24/47] [FIX] duplicate ids in attachment_synchronize_task_views and flake8 --- attachment_synchronize/__manifest__.py | 2 +- .../models/attachment_synchronize_task.py | 15 ++++++++------- .../views/attachment_synchronize_task_views.xml | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index 2c8dfa80d31..b50774c2b44 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -12,7 +12,7 @@ "category": "Generic Modules", "depends": [ "attachment_queue", - "storage_backend", # https://github.com/OCA/storage + "storage_backend", # https://github.com/OCA/storage ], "data": [ "views/attachment_queue_views.xml", diff --git a/attachment_synchronize/models/attachment_synchronize_task.py b/attachment_synchronize/models/attachment_synchronize_task.py index 257b3c21e63..436c79a6ada 100644 --- a/attachment_synchronize/models/attachment_synchronize_task.py +++ b/attachment_synchronize/models/attachment_synchronize_task.py @@ -85,20 +85,21 @@ class AttachmentSynchronizeTask(models.Model): file_type = fields.Selection( selection=[], string="File Type", - help="The file type allows Odoo to recognize what to do with the files " - "once imported.", + help="Used to fill the 'File Type' field in the imported 'Attachments Queues'." + "\nFurther operations will be realized on these Attachments Queues depending " + "on their 'File Type' value.", ) enabled = fields.Boolean("Enabled", default=True) avoid_duplicated_files = fields.Boolean( string="Avoid importing duplicated files", - help="If checked, a file will not be imported if there is already an " - "Attachment Queue with the same name.", + help="If checked, a file will not be imported if an Attachment Queue with the " + "same name already exists.", ) failure_emails = fields.Char( string="Failure Emails", - help="Used to fill the 'Failure Emails' fields in the 'Attachments Queues' " - "related to this task.\nThese emails will be notified if any operation on these " - "Attachment Queue's file type fails.", + help="Used to fill the 'Failure Emails' field in the 'Attachments Queues' " + "related to this task.\nAn alert will be sent to these emails if any operation " + "on these Attachment Queue's file type fails.", ) def _prepare_attachment_vals(self, data, filename): diff --git a/attachment_synchronize/views/attachment_synchronize_task_views.xml b/attachment_synchronize/views/attachment_synchronize_task_views.xml index 28a6812ad0d..7fe1d7999a6 100644 --- a/attachment_synchronize/views/attachment_synchronize_task_views.xml +++ b/attachment_synchronize/views/attachment_synchronize_task_views.xml @@ -123,14 +123,14 @@ {'default_method_type': 'import'}
- + tree - + form @@ -155,14 +155,14 @@ {'default_method_type': 'export'} - + tree - + form From ee275d029ad7a186b277eba2e021780474337789 Mon Sep 17 00:00:00 2001 From: clementmbr Date: Thu, 27 Aug 2020 09:45:52 +0200 Subject: [PATCH 25/47] [UPD] Add akretion maintainers --- attachment_synchronize/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index b50774c2b44..c816b1f7195 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -8,6 +8,7 @@ "version": "12.0.1.0.0", "author": "Akretion,Odoo Community Association (OCA)", "website": "https://github.com/oca/server-tools", + "maintainers": ["florian-dacosta", "sebastienbeau", "GSLabIt", "bealdav"], "license": "AGPL-3", "category": "Generic Modules", "depends": [ @@ -24,5 +25,4 @@ "demo": ["demo/attachment_synchronize_task_demo.xml"], "installable": True, "development_status": "Beta", - "maintainers": ["florian-dacosta", "GSLabIt", "bealdav"], } From 005b8f84511d8b9ef907ce000401d327670f3ba3 Mon Sep 17 00:00:00 2001 From: David Beal Date: Thu, 27 Aug 2020 16:23:00 +0200 Subject: [PATCH 26/47] FIX attach_synchro: clean demo data --- .../demo/attachment_synchronize_task_demo.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/attachment_synchronize/demo/attachment_synchronize_task_demo.xml b/attachment_synchronize/demo/attachment_synchronize_task_demo.xml index d3f8b45f3b2..f8e9879eb80 100644 --- a/attachment_synchronize/demo/attachment_synchronize_task_demo.xml +++ b/attachment_synchronize/demo/attachment_synchronize_task_demo.xml @@ -6,7 +6,6 @@ import delete test_import - foo@example.org,bar@example.org @@ -14,7 +13,6 @@ export test_export - foo@example.org,bar@example.org From d5f00d5ce4f3e1f6c49173aee00273b13546201c Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 27 Aug 2020 15:02:56 +0000 Subject: [PATCH 27/47] [UPD] Update attachment_synchronize.pot --- .../i18n/attachment_synchronize.pot | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 attachment_synchronize/i18n/attachment_synchronize.pot diff --git a/attachment_synchronize/i18n/attachment_synchronize.pot b/attachment_synchronize/i18n/attachment_synchronize.pot new file mode 100644 index 00000000000..2ce1219abde --- /dev/null +++ b/attachment_synchronize/i18n/attachment_synchronize.pot @@ -0,0 +1,308 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * attachment_synchronize +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.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: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__after_import +msgid "Action after import a file" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__after_import +msgid "After Import" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__attachment_ids +msgid "Attachment" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_search +msgid "Attachment Task" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model,name:attachment_synchronize.model_attachment_synchronize_task +msgid "Attachment synchronize task" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_form +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_search +msgid "Attachments" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.actions.act_window,name:attachment_synchronize.action_attachment_export_task +#: model:ir.ui.menu,name:attachment_synchronize.menu_attachment_export_task +msgid "Attachments Export Tasks" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.actions.act_window,name:attachment_synchronize.action_attachment_import_task +#: model:ir.ui.menu,name:attachment_synchronize.menu_attachment_import_task +msgid "Attachments Import Tasks" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__avoid_duplicated_files +msgid "Avoid importing duplicated files" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__backend_id +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_search +msgid "Backend" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_export_task_tree +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_import_task_tree +msgid "Copy" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__create_uid +msgid "Created by" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__create_date +msgid "Created on" +msgstr "" + +#. module: attachment_synchronize +#: selection:attachment.synchronize.task,after_import:0 +msgid "Delete" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__display_name +msgid "Display Name" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_export_task_tree +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_import_task_tree +msgid "Enable" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__enabled +msgid "Enabled" +msgstr "" + +#. module: attachment_synchronize +#: selection:attachment.queue,file_type:0 +msgid "Export File (External location)" +msgstr "" + +#. module: attachment_synchronize +#: selection:attachment.synchronize.task,method_type:0 +msgid "Export Task" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_storage_backend__export_task_count +msgid "Export Tasks" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__failure_emails +msgid "Failure Emails" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__filepath +msgid "File Path" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_queue__file_type +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__file_type +msgid "File Type" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__id +msgid "ID" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__avoid_duplicated_files +msgid "If checked, a file will not be imported if an Attachment Queue with the same name already exists." +msgstr "" + +#. module: attachment_synchronize +#: selection:attachment.synchronize.task,method_type:0 +msgid "Import Task" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_storage_backend__import_task_count +msgid "Import Tasks" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_form +msgid "Importation" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__move_path +msgid "Imported File will be moved to this path" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__new_name +msgid "Imported File will be renamed to this name.\n" +"New Name can use 'mako' template where 'obj' is the original file's name.\n" +"For instance : ${obj.name}-${obj.create_date}.csv" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task____last_update +msgid "Last Modified on" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__write_date +msgid "Last Updated on" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_queue__method_type +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__method_type +msgid "Method Type" +msgstr "" + +#. module: attachment_synchronize +#: selection:attachment.synchronize.task,after_import:0 +msgid "Move" +msgstr "" + +#. module: attachment_synchronize +#: selection:attachment.synchronize.task,after_import:0 +msgid "Move & Rename" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__move_path +msgid "Move Path" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__name +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_form +msgid "Name" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__new_name +msgid "New Name" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__filepath +msgid "Path to imported/exported files in the Backend" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__pattern +msgid "Pattern used to select the files to be imported following the 'fnmatch' special characters (e.g. '*.txt' to catch all the text files).\n" +"If empty, import all the files found in 'File Path'." +msgstr "" + +#. module: attachment_synchronize +#: selection:attachment.synchronize.task,after_import:0 +msgid "Rename" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_form +msgid "Run Export" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_form +msgid "Run Import" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.actions.server,name:attachment_synchronize.cronjob_run_attachment_synchronize_task_import_ir_actions_server +#: model:ir.cron,cron_name:attachment_synchronize.cronjob_run_attachment_synchronize_task_import +#: model:ir.cron,name:attachment_synchronize.cronjob_run_attachment_synchronize_task_import +msgid "Run attachment tasks import" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__pattern +msgid "Selection Pattern" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_queue__storage_backend_id +msgid "Storage Backend" +msgstr "" + +#. module: attachment_synchronize +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_task_form +msgid "Storage Location" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_queue__task_id +msgid "Task" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_storage_backend__synchronize_task_ids +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_export_task_tree +#: model_terms:ir.ui.view,arch_db:attachment_synchronize.view_attachment_import_task_tree +msgid "Tasks" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_queue__file_type +msgid "The file type determines an import method to be used to parse and transform data before their import in ERP or an export" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__failure_emails +msgid "Used to fill the 'Failure Emails' field in the 'Attachments Queues' related to this task.\n" +"An alert will be sent to these emails if any operation on these Attachment Queue's file type fails." +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model.fields,help:attachment_synchronize.field_attachment_synchronize_task__file_type +msgid "Used to fill the 'File Type' field in the imported 'Attachments Queues'.\n" +"Further operations will be realized on these Attachments Queues depending on their 'File Type' value." +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model,name:attachment_synchronize.model_attachment_queue +msgid "attachment.queue" +msgstr "" + +#. module: attachment_synchronize +#: model:ir.model,name:attachment_synchronize.model_storage_backend +msgid "storage.backend" +msgstr "" + From e8ae3329ac8101be2188fa21999649a405f1e5d2 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 27 Aug 2020 15:13:19 +0000 Subject: [PATCH 28/47] [UPD] README.rst --- attachment_synchronize/README.rst | 5 ++++- attachment_synchronize/static/description/index.html | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/attachment_synchronize/README.rst b/attachment_synchronize/README.rst index 4da14ae516b..e625b85faaf 100644 --- a/attachment_synchronize/README.rst +++ b/attachment_synchronize/README.rst @@ -107,6 +107,9 @@ promote its widespread use. .. |maintainer-florian-dacosta| image:: https://github.com/florian-dacosta.png?size=40px :target: https://github.com/florian-dacosta :alt: florian-dacosta +.. |maintainer-sebastienbeau| image:: https://github.com/sebastienbeau.png?size=40px + :target: https://github.com/sebastienbeau + :alt: sebastienbeau .. |maintainer-GSLabIt| image:: https://github.com/GSLabIt.png?size=40px :target: https://github.com/GSLabIt :alt: GSLabIt @@ -116,7 +119,7 @@ promote its widespread use. Current `maintainers `__: -|maintainer-florian-dacosta| |maintainer-GSLabIt| |maintainer-bealdav| +|maintainer-florian-dacosta| |maintainer-sebastienbeau| |maintainer-GSLabIt| |maintainer-bealdav| This module is part of the `OCA/server-tools `_ project on GitHub. diff --git a/attachment_synchronize/static/description/index.html b/attachment_synchronize/static/description/index.html index f378652c7cf..754b6be5f68 100644 --- a/attachment_synchronize/static/description/index.html +++ b/attachment_synchronize/static/description/index.html @@ -438,7 +438,7 @@

Maintainers

mission is to support the collaborative development of Odoo features and promote its widespread use.

Current maintainers:

-

florian-dacosta GSLabIt bealdav

+

florian-dacosta sebastienbeau GSLabIt bealdav

This module is part of the OCA/server-tools project on GitHub.

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

From ac287fe3e1614e79c0fd5bd3fa7225e4e9447a4f Mon Sep 17 00:00:00 2001 From: oca-travis Date: Wed, 14 Oct 2020 16:45:14 +0000 Subject: [PATCH 29/47] [UPD] Update attachment_synchronize.pot --- attachment_synchronize/i18n/attachment_synchronize.pot | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/attachment_synchronize/i18n/attachment_synchronize.pot b/attachment_synchronize/i18n/attachment_synchronize.pot index 2ce1219abde..336d1d963a5 100644 --- a/attachment_synchronize/i18n/attachment_synchronize.pot +++ b/attachment_synchronize/i18n/attachment_synchronize.pot @@ -23,6 +23,11 @@ msgstr "" msgid "After Import" msgstr "" +#. module: attachment_synchronize +#: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__assigned_attachment_ids +msgid "Assigned Attachments" +msgstr "" + #. module: attachment_synchronize #: model:ir.model.fields,field_description:attachment_synchronize.field_attachment_synchronize_task__attachment_ids msgid "Attachment" From e5c210661ac0e3aa4ceb5ef3ff84ce07423c7ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Fri, 23 Oct 2020 16:23:29 +0200 Subject: [PATCH 30/47] [REF] refactor code, rename enabled to active, and simplifie run code --- .../models/attachment_synchronize_task.py | 18 ++++++++----- attachment_synchronize/tests/test_import.py | 2 +- .../attachment_synchronize_task_views.xml | 26 +++++++++---------- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/attachment_synchronize/models/attachment_synchronize_task.py b/attachment_synchronize/models/attachment_synchronize_task.py index 436c79a6ada..25688717e58 100644 --- a/attachment_synchronize/models/attachment_synchronize_task.py +++ b/attachment_synchronize/models/attachment_synchronize_task.py @@ -89,7 +89,7 @@ class AttachmentSynchronizeTask(models.Model): "\nFurther operations will be realized on these Attachments Queues depending " "on their 'File Type' value.", ) - enabled = fields.Boolean("Enabled", default=True) + active = fields.Boolean("Enabled", default=True, old="enabled") avoid_duplicated_files = fields.Boolean( string="Avoid importing duplicated files", help="If checked, a file will not be imported if an Attachment Queue with the " @@ -139,11 +139,19 @@ def run_task_import_scheduler(self, domain=None): if domain is None: domain = [] domain = expression.AND( - [domain, [("method_type", "=", "import"), ("enabled", "=", True)]] + [domain, [("method_type", "=", "import")]] ) for task in self.search(domain): task.run_import() + def run(self): + for record in self: + method = "run_{}".format(record.method_type) + if not hasattr(self, method): + raise NotImplemented + else: + getattr(record, method)() + def run_import(self): self.ensure_one() attach_obj = self.env["attachment.queue"] @@ -200,10 +208,6 @@ def run_export(self): for task in self: task.attachment_ids.filtered(lambda a: a.state == "pending").run() - def button_toogle_enabled(self): - for rec in self: - rec.enabled = not rec.enabled - def button_duplicate_record(self): self.ensure_one() - self.copy({"enabled": False}) + self.copy({"active": False}) diff --git a/attachment_synchronize/tests/test_import.py b/attachment_synchronize/tests/test_import.py index 61155576303..a8b0031fcf7 100644 --- a/attachment_synchronize/tests/test_import.py +++ b/attachment_synchronize/tests/test_import.py @@ -78,6 +78,6 @@ def test_running_cron(self): self._check_attachment_created(count=1) def test_running_cron_disable_task(self): - self.task.write({"after_import": "delete", "enabled": False}) + self.task.write({"after_import": "delete", "active": False}) self.env["attachment.synchronize.task"].run_task_import_scheduler() self._check_attachment_created(count=0) diff --git a/attachment_synchronize/views/attachment_synchronize_task_views.xml b/attachment_synchronize/views/attachment_synchronize_task_views.xml index 7fe1d7999a6..18f4a2dbc4d 100644 --- a/attachment_synchronize/views/attachment_synchronize_task_views.xml +++ b/attachment_synchronize/views/attachment_synchronize_task_views.xml @@ -5,16 +5,13 @@
-
-
-
- + + - - -
- -

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

+feedback.

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

@@ -439,7 +439,7 @@

Maintainers

promote its widespread use.

Current maintainers:

florian-dacosta sebastienbeau GSLabIt bealdav

-

This module is part of the OCA/server-tools project on GitHub.

+

This module is part of the OCA/server-tools project on GitHub.

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

From c04cc441c963e81354c9cd92631145f725c08338 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 15 Apr 2022 12:59:37 +0000 Subject: [PATCH 41/47] attachment_synchronize 14.0.1.0.1 --- attachment_synchronize/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index d28838897e1..c34f571de55 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Attachment Synchronize", - "version": "14.0.1.0.0", + "version": "14.0.1.0.1", "author": "Akretion,Odoo Community Association (OCA)", "website": "https://github.com/OCA/server-tools", "maintainers": ["florian-dacosta", "sebastienbeau", "GSLabIt", "bealdav"], From e863dd09f7d6582c1846b7573effd64c85cae20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=BAria=20Sancho?= Date: Mon, 13 Feb 2023 11:21:42 +0100 Subject: [PATCH 42/47] [14.0][FIX] storage_backend: fix read --- attachment_synchronize/models/storage_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/attachment_synchronize/models/storage_backend.py b/attachment_synchronize/models/storage_backend.py index 8bee3cf9376..6b0955c3215 100644 --- a/attachment_synchronize/models/storage_backend.py +++ b/attachment_synchronize/models/storage_backend.py @@ -32,7 +32,7 @@ def action_related_import_task(self): self.ensure_one() act_window_xml_id = "attachment_synchronize.action_attachment_import_task" - act_window = self.env.ref(act_window_xml_id).read()[0] + act_window = self.env["ir.actions.act_window"]._for_xml_id(act_window_xml_id) domain = [ ("id", "in", self.synchronize_task_ids.ids), ("method_type", "=", "import"), @@ -51,7 +51,7 @@ def action_related_export_task(self): self.ensure_one() act_window_xml_id = "attachment_synchronize.action_attachment_export_task" - act_window = self.env.ref(act_window_xml_id).read()[0] + act_window = self.env["ir.actions.act_window"]._for_xml_id(act_window_xml_id) domain = [ ("id", "in", self.synchronize_task_ids.ids), ("method_type", "=", "export"), From db92364a30f9aef95d0add1524b28415e2b6efd5 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 14 Feb 2023 12:53:25 +0000 Subject: [PATCH 43/47] attachment_synchronize 14.0.1.0.2 --- attachment_synchronize/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index c34f571de55..b7b6722ed26 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Attachment Synchronize", - "version": "14.0.1.0.1", + "version": "14.0.1.0.2", "author": "Akretion,Odoo Community Association (OCA)", "website": "https://github.com/OCA/server-tools", "maintainers": ["florian-dacosta", "sebastienbeau", "GSLabIt", "bealdav"], From a73d811f1986a7445c3c36360839bdfa59e54728 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Thu, 25 May 2023 18:00:34 +0200 Subject: [PATCH 44/47] [IMP] attachment_synchronize: black, isort, prettier --- .../odoo/addons/attachment_synchronize | 1 + setup/attachment_synchronize/setup.py | 6 ++++++ 2 files changed, 7 insertions(+) create mode 120000 setup/attachment_synchronize/odoo/addons/attachment_synchronize create mode 100644 setup/attachment_synchronize/setup.py diff --git a/setup/attachment_synchronize/odoo/addons/attachment_synchronize b/setup/attachment_synchronize/odoo/addons/attachment_synchronize new file mode 120000 index 00000000000..771cca7f5b1 --- /dev/null +++ b/setup/attachment_synchronize/odoo/addons/attachment_synchronize @@ -0,0 +1 @@ +../../../../attachment_synchronize \ No newline at end of file diff --git a/setup/attachment_synchronize/setup.py b/setup/attachment_synchronize/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/attachment_synchronize/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From deaa0b805fd5a488c3bdcc93431af4b47e0b2c33 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Thu, 25 May 2023 18:03:34 +0200 Subject: [PATCH 45/47] [MIG] attachment_synchronize : Migration to v16 --- attachment_synchronize/__manifest__.py | 4 +- .../demo/attachment_synchronize_task_demo.xml | 4 +- .../models/attachment_queue.py | 24 ++-- .../models/attachment_synchronize_task.py | 111 ++++++++++-------- .../models/storage_backend.py | 4 +- attachment_synchronize/readme/DESCRIPTION.rst | 4 +- attachment_synchronize/tests/common.py | 31 ++--- attachment_synchronize/tests/test_export.py | 12 +- attachment_synchronize/tests/test_import.py | 12 +- .../views/attachment_queue_views.xml | 4 +- .../attachment_synchronize_task_views.xml | 25 ++-- .../views/storage_backend_views.xml | 6 +- 12 files changed, 136 insertions(+), 105 deletions(-) diff --git a/attachment_synchronize/__manifest__.py b/attachment_synchronize/__manifest__.py index b7b6722ed26..bcf60d65ad9 100644 --- a/attachment_synchronize/__manifest__.py +++ b/attachment_synchronize/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Attachment Synchronize", - "version": "14.0.1.0.2", + "version": "16.0.1.0.0", "author": "Akretion,Odoo Community Association (OCA)", "website": "https://github.com/OCA/server-tools", "maintainers": ["florian-dacosta", "sebastienbeau", "GSLabIt", "bealdav"], @@ -13,7 +13,7 @@ "category": "Generic Modules", "depends": [ "attachment_queue", - "storage_backend", # https://github.com/OCA/storage + "fs_storage", # https://github.com/OCA/storage ], "data": [ "views/attachment_queue_views.xml", diff --git a/attachment_synchronize/demo/attachment_synchronize_task_demo.xml b/attachment_synchronize/demo/attachment_synchronize_task_demo.xml index aa235567bd7..99534417802 100644 --- a/attachment_synchronize/demo/attachment_synchronize_task_demo.xml +++ b/attachment_synchronize/demo/attachment_synchronize_task_demo.xml @@ -2,7 +2,7 @@ TEST Import - + import delete test_import @@ -10,7 +10,7 @@ TEST Export - + export test_export diff --git a/attachment_synchronize/models/attachment_queue.py b/attachment_synchronize/models/attachment_queue.py index c161f28e184..9f9861e5305 100644 --- a/attachment_synchronize/models/attachment_queue.py +++ b/attachment_synchronize/models/attachment_queue.py @@ -1,7 +1,7 @@ # @ 2016 Florian DA COSTA @ Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import os +import base64 from odoo import api, fields, models @@ -11,9 +11,9 @@ class AttachmentQueue(models.Model): task_id = fields.Many2one("attachment.synchronize.task", string="Task") method_type = fields.Selection(related="task_id.method_type") - storage_backend_id = fields.Many2one( - "storage.backend", - string="Storage Backend", + fs_storage_id = fields.Many2one( + "fs.storage", + string="Filestore Storage", related="task_id.backend_id", store=True, ) @@ -22,10 +22,20 @@ class AttachmentQueue(models.Model): ) def _run(self): - super()._run() + res = super()._run() if self.file_type == "export": - path = os.path.join(self.task_id.filepath, self.name) - self.storage_backend_id._add_b64_data(path, self.datas) + fs = self.fs_storage_id.fs + folder_path = self.task_id.filepath + full_path = ( + folder_path and fs.sep.join([folder_path, self.name]) or self.name + ) + # create missing folders if necessary : + if folder_path and not fs.exists(folder_path): + fs.makedirs(folder_path) + data = base64.b64decode(self.datas) + with fs.open(full_path, "wb") as f: + f.write(data) + return res def _get_failure_emails(self): res = super()._get_failure_emails() diff --git a/attachment_synchronize/models/attachment_synchronize_task.py b/attachment_synchronize/models/attachment_synchronize_task.py index ca878d85797..723f8af2622 100644 --- a/attachment_synchronize/models/attachment_synchronize_task.py +++ b/attachment_synchronize/models/attachment_synchronize_task.py @@ -1,10 +1,9 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import base64 import datetime import logging -import os -import odoo from odoo import api, fields, models, tools from odoo.osv import expression @@ -62,13 +61,10 @@ class AttachmentSynchronizeTask(models.Model): filepath = fields.Char( string="File Path", help="Path to imported/exported files in the Backend" ) - backend_id = fields.Many2one("storage.backend", string="Backend") + backend_id = fields.Many2one("fs.storage", string="Backend") attachment_ids = fields.One2many("attachment.queue", "task_id", string="Attachment") - move_path = fields.Char( - string="Move Path", help="Imported File will be moved to this path" - ) + move_path = fields.Char(help="Imported File will be moved to this path") new_name = fields.Char( - string="New Name", help="Imported File will be renamed to this name.\n" "New Name can use 'mako' template where 'obj' is the original file's name.\n" "For instance : ${obj.name}-${obj.create_date}.csv", @@ -84,7 +80,6 @@ class AttachmentSynchronizeTask(models.Model): ) file_type = fields.Selection( selection=[], - string="File Type", help="Used to fill the 'File Type' field in the imported 'Attachments Queues'." "\nFurther operations will be realized on these Attachments Queues depending " "on their 'File Type' value.", @@ -96,7 +91,6 @@ class AttachmentSynchronizeTask(models.Model): "same name already exists.", ) failure_emails = fields.Char( - string="Failure Emails", help="Used to fill the 'Failure Emails' field in the 'Attachments Queues' " "related to this task.\nAn alert will be sent to these emails if any operation " "on these Attachment Queue's file type fails.", @@ -159,61 +153,78 @@ def run(self): else: getattr(record, method)() - def run_import(self): + def _get_files(self): self.ensure_one() - attach_obj = self.env["attachment.queue"] - backend = self.backend_id + fs = self.backend_id.fs filepath = self.filepath or "" - filenames = backend._list(relative_path=filepath, pattern=self.pattern) + if filepath and not fs.exists(filepath): + return [] + if self.pattern: + path = filepath and fs.sep.join([filepath, self.pattern]) or self.pattern + file_path_names = fs.glob(path) + else: + file_path_names = fs.ls(filepath, detail=False) if self.avoid_duplicated_files: - filenames = self._file_to_import(filenames) + file_path_names = self._filter_duplicates(file_path_names) + return file_path_names + + def _manage_file_after_import(self, file_name, fullpath, attachment): + self.ensure_one() + fs = self.backend_id.fs + new_full_path = False + if self.after_import == "rename": + new_name = self._template_render(self.new_name, attachment) + new_full_path = fs.sep.join([self.filepath, new_name]) + elif self.after_import == "move": + new_full_path = fs.sep.join([self.move_path, file_name]) + elif self.after_import == "move_rename": + new_name = self._template_render(self.new_name, attachment) + new_full_path = fs.sep.join([self.move_path, new_name]) + if new_full_path: + fs.move(fullpath, new_full_path) + if self.after_import == "delete": + fs.rm(fullpath) + + def run_import(self): + self.ensure_one() + attach_obj = self.env["attachment.queue"] + file_path_names = self._get_files() total_import = 0 - for file_name in filenames: - with api.Environment.manage(): - with odoo.registry(self.env.cr.dbname).cursor() as new_cr: - new_env = api.Environment(new_cr, self.env.uid, self.env.context) - try: - full_absolute_path = os.path.join(filepath, file_name) - data = backend._get_b64_data(full_absolute_path) - attach_vals = self._prepare_attachment_vals(data, file_name) - attachment = attach_obj.with_env(new_env).create(attach_vals) - new_full_path = False - if self.after_import == "rename": - new_name = self._template_render(self.new_name, attachment) - new_full_path = os.path.join(filepath, new_name) - elif self.after_import == "move": - new_full_path = os.path.join(self.move_path, file_name) - elif self.after_import == "move_rename": - new_name = self._template_render(self.new_name, attachment) - new_full_path = os.path.join(self.move_path, new_name) - if new_full_path: - backend._add_b64_data(new_full_path, data) - if self.after_import in ( - "delete", - "rename", - "move", - "move_rename", - ): - backend._delete(full_absolute_path) - total_import += 1 - except Exception as e: - new_env.cr.rollback() - raise e - else: - new_env.cr.commit() + fs = self.backend_id.fs + for file_path in file_path_names: + if fs.isdir(file_path): + continue + with self.env.cr.savepoint(): + file_name = file_path.split(fs.sep)[-1] + data = fs.read_bytes(file_path) + data = base64.b64encode(data) + attach_vals = self._prepare_attachment_vals(data, file_name) + attachment = attach_obj.create(attach_vals) + self._manage_file_after_import(file_name, file_path, attachment) + total_import += 1 _logger.info("Run import complete! Imported {} files".format(total_import)) - def _file_to_import(self, filenames): + def _filter_duplicates(self, file_path_names): + fs = self.backend_id.fs + self.ensure_one() + if self.filepath: + filenames = [x.split(fs.sep)[-1] for x in file_path_names] + else: + filenames = file_path_names imported = ( self.env["attachment.queue"] .search([("name", "in", filenames)]) .mapped("name") ) - return list(set(filenames) - set(imported)) + file_path_names_no_duplicate = [ + x for x in file_path_names if x.split(fs.sep)[-1] not in imported + ] + return file_path_names_no_duplicate def run_export(self): for task in self: - task.attachment_ids.filtered(lambda a: a.state == "pending").run() + for att in task.attachment_ids.filtered(lambda a: a.state == "pending"): + att.run_as_job() def button_duplicate_record(self): # due to orm limitation method call from ui should not have params diff --git a/attachment_synchronize/models/storage_backend.py b/attachment_synchronize/models/storage_backend.py index 6b0955c3215..15aabe14840 100644 --- a/attachment_synchronize/models/storage_backend.py +++ b/attachment_synchronize/models/storage_backend.py @@ -3,8 +3,8 @@ from odoo import fields, models -class StorageBackend(models.Model): - _inherit = "storage.backend" +class FsStorage(models.Model): + _inherit = "fs.storage" synchronize_task_ids = fields.One2many( "attachment.synchronize.task", "backend_id", string="Tasks" diff --git a/attachment_synchronize/readme/DESCRIPTION.rst b/attachment_synchronize/readme/DESCRIPTION.rst index a161774b6af..fb181967b0b 100644 --- a/attachment_synchronize/readme/DESCRIPTION.rst +++ b/attachment_synchronize/readme/DESCRIPTION.rst @@ -1,5 +1,5 @@ This module allows to **import/export files** from/to backend servers. -A backend server is defined by the basic `storage_backend `_ OCA module, while it can be configured (amazon S3, sftp,...) with additional modules from the `storage `_ repository. +A backend server is defined by the basic `fs_storage `_ OCA module, while it can be configured (amazon S3, sftp,...) with additional modules fs python libraries -The imported files (and the files to be exported) are stored in Odoo as ``attachment.queue`` objects, defined by the `attachment_queue `_ module while the importation itself (resp. exportation) is realized by **"Attachments Import Tasks"** (resp. "Attachments Export Tasks") defined by this current module. +The imported files (and the files to be exported) are stored in Odoo as ``attachment.queue`` objects, defined by the `attachment_queue `_ module while the importation itself (resp. exportation) is realized by **"Attachments Import Tasks"** (resp. "Attachments Export Tasks") defined by this current module. diff --git a/attachment_synchronize/tests/common.py b/attachment_synchronize/tests/common.py index f10c2e6bd32..ac4155ae7d7 100644 --- a/attachment_synchronize/tests/common.py +++ b/attachment_synchronize/tests/common.py @@ -2,34 +2,36 @@ # @author Sébastien BEAU # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import os +import base64 -import mock +from odoo.tests.common import TransactionCase -from odoo.addons.storage_backend.tests.common import CommonCase - -class SyncCommon(CommonCase): +class SyncCommon(TransactionCase): def _clean_testing_directory(self): for test_dir in [ self.directory_input, self.directory_output, self.directory_archived, ]: - for filename in self.backend.list_files(test_dir): - self.backend.delete(os.path.join(test_dir, filename)) + fs = self.backend.fs + if not fs.exists(test_dir): + fs.makedirs(test_dir) + for filename in fs.ls(test_dir, detail=False): + fs.rm(filename) def _create_test_file(self): - self.backend._add_b64_data( - os.path.join(self.directory_input, "bar.txt"), - self.filedata, - mimetype="text/plain", - ) + fs = self.backend.fs + path = fs.sep.join([self.directory_input, "bar.txt"]) + with fs.open(path, "wb") as f: + f.write(self.filedata) def setUp(self): super().setUp() - self.env.cr.commit = mock.Mock() - self.registry.enter_test_mode(self.env.cr) + # self.env.cr.commit = mock.Mock() + # self.registry.enter_test_mode(self.env.cr) + self.backend = self.env.ref("fs_storage.default_fs_storage") + self.filedata = base64.b64encode(b"This is a simple file") self.directory_input = "test_import" self.directory_output = "test_export" self.directory_archived = "test_archived" @@ -38,6 +40,5 @@ def setUp(self): self.task = self.env.ref("attachment_synchronize.import_from_filestore") def tearDown(self): - self.registry.leave_test_mode() self._clean_testing_directory() super().tearDown() diff --git a/attachment_synchronize/tests/test_export.py b/attachment_synchronize/tests/test_export.py index 9581fa92649..696b4e67a06 100644 --- a/attachment_synchronize/tests/test_export.py +++ b/attachment_synchronize/tests/test_export.py @@ -2,7 +2,7 @@ # @author Sébastien BEAU # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import mock +from unittest import mock from odoo.tools import mute_logger @@ -28,16 +28,16 @@ def setUp(self): def test_export(self): self.attachment.run() - result = self.backend._list("test_export") - self.assertEqual(result, ["foo.txt"]) + result = self.backend.fs.ls("test_export", detail=False) + self.assertEqual(result, ["test_export/foo.txt"]) @mute_logger("odoo.addons.attachment_queue.models.attachment_queue") def test_failing_export(self): with mock.patch.object( - type(self.backend), - "_add_b64_data", + type(self.backend.fs), + "open", side_effect=raising_side_effect, ): - self.attachment.run() + self.attachment.with_context(queue_job__no_delay=True).run_as_job() self.assertEqual(self.attachment.state, "failed") self.assertEqual(self.attachment.state_message, "Boom") diff --git a/attachment_synchronize/tests/test_import.py b/attachment_synchronize/tests/test_import.py index 5cc900087fc..cd20c283ec0 100644 --- a/attachment_synchronize/tests/test_import.py +++ b/attachment_synchronize/tests/test_import.py @@ -8,21 +8,21 @@ class TestImport(SyncCommon): @property def archived_files(self): - return self.backend._list(self.directory_archived) + return self.backend.fs.ls(self.directory_archived, detail=False) @property def input_files(self): - return self.backend._list(self.directory_input) + return self.backend.fs.ls(self.directory_input, detail=False) def _check_attachment_created(self, count=1): attachment = self.env["attachment.queue"].search([("name", "=", "bar.txt")]) self.assertEqual(len(attachment), count) def test_import_with_rename(self): - self.task.write({"after_import": "rename", "new_name": "foo.txt"}) + self.task.write({"after_import": "rename", "new_name": "test-${obj.name}"}) self.task.run_import() self._check_attachment_created() - self.assertEqual(self.input_files, ["foo.txt"]) + self.assertEqual(self.input_files, ["test_import/test-bar.txt"]) self.assertEqual(self.archived_files, []) def test_import_with_move(self): @@ -30,7 +30,7 @@ def test_import_with_move(self): self.task.run_import() self._check_attachment_created() self.assertEqual(self.input_files, []) - self.assertEqual(self.archived_files, ["bar.txt"]) + self.assertEqual(self.archived_files, ["test_archived/bar.txt"]) def test_import_with_move_and_rename(self): self.task.write( @@ -43,7 +43,7 @@ def test_import_with_move_and_rename(self): self.task.run_import() self._check_attachment_created() self.assertEqual(self.input_files, []) - self.assertEqual(self.archived_files, ["foo.txt"]) + self.assertEqual(self.archived_files, ["test_archived/foo.txt"]) def test_import_with_delete(self): self.task.write({"after_import": "delete"}) diff --git a/attachment_synchronize/views/attachment_queue_views.xml b/attachment_synchronize/views/attachment_queue_views.xml index d966129b5cb..23a6e9c09f6 100644 --- a/attachment_synchronize/views/attachment_queue_views.xml +++ b/attachment_synchronize/views/attachment_queue_views.xml @@ -13,7 +13,7 @@ domain="[('method_type', '!=', 'import')]" attrs="{'required': [('file_type', '=', 'export')], 'readonly': [('method_type', '=', 'import')]}" /> - + @@ -27,7 +27,7 @@ - +
diff --git a/attachment_synchronize/views/attachment_synchronize_task_views.xml b/attachment_synchronize/views/attachment_synchronize_task_views.xml index 7b92c212b07..abc713f9f13 100644 --- a/attachment_synchronize/views/attachment_synchronize_task_views.xml +++ b/attachment_synchronize/views/attachment_synchronize_task_views.xml @@ -28,6 +28,7 @@ type="action" class="oe_stat_button" icon="fa-thumbs-o-down" + title="Failed attachments" context="{'search_default_failed': 1}" >
@@ -46,6 +47,7 @@ type="action" class="oe_stat_button" icon="fa-spinner" + title="Pending attachments" context="{'search_default_pending': 1}" >
@@ -64,6 +66,7 @@ type="action" class="oe_stat_button" icon="fa-thumbs-o-up" + title="Done attachments" context="{'search_default_done': 1}" >
@@ -109,13 +112,11 @@ @@ -130,7 +131,7 @@ attachment.synchronize.task - + @@ -140,6 +141,7 @@ name="%(action_attachment_queue_related)d" type="action" icon="fa-thumbs-o-down" + title="Failed attachments" context="{'search_default_failed': 1}" /> @@ -147,6 +149,7 @@ name="%(action_attachment_queue_related)d" type="action" icon="fa-spinner" + title="Pending attachments" context="{'search_default_pending': 1}" /> @@ -154,6 +157,7 @@ name="%(action_attachment_queue_related)d" type="action" icon="fa-thumbs-o-up" + title="Done attachments" context="{'search_default_done': 1}" /> @@ -161,7 +165,12 @@ show it if it's embeded in an other view But it's seem that invisible do not work on button --> -