From a417c3e37eaffde0a1c0dfaf01bac88b0a9376aa Mon Sep 17 00:00:00 2001 From: Mourad El Hadj Mimoune Date: Tue, 1 Mar 2016 16:05:45 +0100 Subject: [PATCH] [WIP] Use pattern in file name & add move and rename file option [WIP] use jinja to render new file name based on simple template [FIX] bug move and rename file option [ADD] add file type on task [FIX] add file type in attachemnt create method [WIP] test rename file [WIP] inherit view from attachment_metadata [FIX] bug of inherit view and menu from attachment_metadata [IMP] add move & rename test to test_sftp [IMP] move file location menu inside automation menu and rename it [IMP] add ir.model.access manager rule [FIX] typing mistake [FIX] access file store without login/password [IMP] reorganize task form view [FIX] api.multi in task run method [FIX] OCA guidelines [FIX] add authors and contributors [FIX] fix pylint [FIX] add oca_dependencies & fixe oca version format [FIX] fix pylint & oca_dependencies [FIX] fix views path [FIX] set application to False --- external_file_location/README.rst | 5 +- external_file_location/__init__.py | 4 +- external_file_location/__openerp__.py | 20 +-- external_file_location/attachment_view.xml | 99 --------------- external_file_location/{ => data}/cron.xml | 0 external_file_location/models/__init__.py | 3 + .../{ => models}/attachment.py | 0 external_file_location/{ => models}/helper.py | 0 .../{ => models}/location.py | 2 +- external_file_location/models/task.py | 115 ++++++++++++++++++ .../security/ir.model.access.csv | 2 + external_file_location/task.py | 86 ------------- external_file_location/tasks/abstract_fs.py | 94 ++++++++++++-- .../{ => tasks}/abstract_task.py | 3 +- external_file_location/tasks/sftp.py | 2 +- external_file_location/tests/test_sftp.py | 69 ++++++++++- .../views/attachment_view.xml | 50 ++++++++ .../{ => views}/location_view.xml | 6 +- external_file_location/{ => views}/menu.xml | 0 .../{ => views}/task_view.xml | 30 ++++- 20 files changed, 365 insertions(+), 225 deletions(-) delete mode 100644 external_file_location/attachment_view.xml rename external_file_location/{ => data}/cron.xml (100%) create mode 100644 external_file_location/models/__init__.py rename external_file_location/{ => models}/attachment.py (100%) rename external_file_location/{ => models}/helper.py (100%) rename external_file_location/{ => models}/location.py (97%) create mode 100644 external_file_location/models/task.py delete mode 100644 external_file_location/task.py rename external_file_location/{ => tasks}/abstract_task.py (89%) create mode 100644 external_file_location/views/attachment_view.xml rename external_file_location/{ => views}/location_view.xml (95%) rename external_file_location/{ => views}/menu.xml (100%) rename external_file_location/{ => views}/task_view.xml (55%) diff --git a/external_file_location/README.rst b/external_file_location/README.rst index d245fa00a..4f39f1707 100644 --- a/external_file_location/README.rst +++ b/external_file_location/README.rst @@ -37,17 +37,18 @@ Credits * Joel Grand-Guillaume Camptocamp * initOS * Valentin CHEMIERE +* Mourad EL HADJ MIMOUNE + Contributors ------------ * Sebastien BEAU +* David BEAL Maintainer ---------- -* Valentin CHEMIERE - .. image:: http://odoo-community.org/logo.png :alt: Odoo Community Association :target: http://odoo-community.org diff --git a/external_file_location/__init__.py b/external_file_location/__init__.py index b1341a77b..47f1a6777 100644 --- a/external_file_location/__init__.py +++ b/external_file_location/__init__.py @@ -1,5 +1,3 @@ -from . import attachment -from . import location -from . import task +from . import models from . import tasks from . import tests diff --git a/external_file_location/__openerp__.py b/external_file_location/__openerp__.py index 5dbe2813c..e544ab733 100644 --- a/external_file_location/__openerp__.py +++ b/external_file_location/__openerp__.py @@ -1,11 +1,12 @@ # 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). { 'name': 'external_file_location', - 'version': '0.0.1', - 'author': 'Akretion', + 'version': '8.0.1.0.0', + 'author': 'Akretion,Odoo Community Association (OCA)', 'website': 'www.akretion.com', 'license': 'AGPL-3', 'category': 'Generic Modules', @@ -19,13 +20,14 @@ ], }, 'data': [ - 'menu.xml', - 'attachment_view.xml', - 'location_view.xml', - 'task_view.xml', - 'cron.xml', + 'views/menu.xml', + 'views/attachment_view.xml', + 'views/location_view.xml', + 'views/task_view.xml', + 'data/cron.xml', 'security/ir.model.access.csv', ], 'installable': True, - 'application': True, - } + 'application': False, + 'images': [], +} diff --git a/external_file_location/attachment_view.xml b/external_file_location/attachment_view.xml deleted file mode 100644 index b11b6c4f6..000000000 --- a/external_file_location/attachment_view.xml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - ir.attachment.metadata - - - - - - - - - - - - - - ir.attachment.metadata - - - - - - - - - - - - - - - ir.attachment.metadata - - - - - - - - - - - - - - - - - - - - - - - - - - Attachments - ir.actions.act_window - ir.attachment.metadata - form - tree,form - - - - - - - - tree - - - - - - - form - - - - - - - - diff --git a/external_file_location/cron.xml b/external_file_location/data/cron.xml similarity index 100% rename from external_file_location/cron.xml rename to external_file_location/data/cron.xml diff --git a/external_file_location/models/__init__.py b/external_file_location/models/__init__.py new file mode 100644 index 000000000..6f4176205 --- /dev/null +++ b/external_file_location/models/__init__.py @@ -0,0 +1,3 @@ +from . import attachment +from . import location +from . import task diff --git a/external_file_location/attachment.py b/external_file_location/models/attachment.py similarity index 100% rename from external_file_location/attachment.py rename to external_file_location/models/attachment.py diff --git a/external_file_location/helper.py b/external_file_location/models/helper.py similarity index 100% rename from external_file_location/helper.py rename to external_file_location/models/helper.py diff --git a/external_file_location/location.py b/external_file_location/models/location.py similarity index 97% rename from external_file_location/location.py rename to external_file_location/models/location.py index 5c0eb7985..8a3d800cb 100644 --- a/external_file_location/location.py +++ b/external_file_location/models/location.py @@ -3,7 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from openerp import models, fields, api -from .abstract_task import AbstractTask +from ..tasks.abstract_task import AbstractTask from .helper import itersubclasses diff --git a/external_file_location/models/task.py b/external_file_location/models/task.py new file mode 100644 index 000000000..64e9478f0 --- /dev/null +++ b/external_file_location/models/task.py @@ -0,0 +1,115 @@ +# coding: utf-8 +# @ 2015 Valentin CHEMIERE @ Akretion +# © @author Mourad EL HADJ MIMOUNE +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import models, fields, api +from .helper import itersubclasses, get_erp_module, is_module_installed +from ..tasks.abstract_task import AbstractTask + + +class Task(models.Model): + _name = 'external.file.task' + _description = 'External file task' + + name = fields.Char(required=True) + method = fields.Selection(selection='_get_method', required=True, + help='procotol and trasmitting info') + method_type = fields.Char() + filename = fields.Char(help='File name which is imported.' + 'You can use file pattern like *.txt' + 'to import all txt files') + filepath = fields.Char(help='Path to imported file') + location_id = fields.Many2one('external.file.location', string='Location', + 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' + 'Name can use mako template where obj is an ' + 'ir_attachement. template exemple : ' + ' ${obj.name}-${obj.create_date}.csv') + md5_check = fields.Boolean(help='Control file integrity after import with' + ' a md5 file') + after_import = fields.Selection(selection='_get_action', + help='Action after import a file') + file_type = fields.Selection( + selection="_get_file_type", + string="File type", + help="The file type determines an import method to be used " + "to parse and transform data before their import in ERP") + + def _get_action(self): + return [('rename', 'Rename'), + ('move', 'Move'), + ('move_rename', 'Move & Rename'), + ('delete', 'Delete'), + ] + + def _get_file_type(self): + """This is the method to be inherited for adding file types + The basic import do not apply any parsing or transform of the file. + The file is just added as an attachement + """ + return [('basic_import', 'Basic import')] + + def _get_method(self): + res = [] + for cls in itersubclasses(AbstractTask): + if not is_module_installed(self.env, get_erp_module(cls)): + continue + if cls._synchronize_type and ( + 'protocol' not in self._context or + cls._key == self._context['protocol']): + cls_info = (cls._key + '_' + cls._synchronize_type, + cls._name + ' ' + cls._synchronize_type) + res.append(cls_info) + return res + + @api.onchange('method') + def onchange_method(self): + if self.method: + if 'import' in self.method: + self.method_type = 'import' + elif 'export' in self.method: + self.method_type = 'export' + + @api.model + def _run(self, domain=None): + if not domain: + domain = [] + tasks = self.env['external.file.task'].search(domain) + tasks.run() + + @api.multi + def run(self): + for tsk in self: + for cls in itersubclasses(AbstractTask): + if not is_module_installed(self.env, get_erp_module(cls)): + continue + cls_build = '%s_%s' % (cls._key, cls._synchronize_type) + if cls._synchronize_type and cls_build == tsk.method: + method_class = cls + config = { + 'host': tsk.location_id.address, + # ftplib does not support unicode + 'user': tsk.location_id.login and\ + tsk.location_id.login.encode('utf-8'), + 'pwd': tsk.location_id.password and \ + tsk.location_id.password.encode('utf-8'), + 'port': tsk.location_id.port, + 'allow_dir_creation': False, + 'file_name': tsk.filename, + 'path': tsk.filepath, + 'attachment_ids': tsk.attachment_ids, + 'task': tsk, + 'move_path': tsk.move_path, + 'new_name': tsk.new_name, + 'after_import': tsk.after_import, + 'file_type': tsk.file_type, + 'md5_check': tsk.md5_check, + } + conn = method_class(self.env, config) + conn.run() diff --git a/external_file_location/security/ir.model.access.csv b/external_file_location/security/ir.model.access.csv index ae2ce783b..37961f195 100644 --- a/external_file_location/security/ir.model.access.csv +++ b/external_file_location/security/ir.model.access.csv @@ -1,3 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_external_file_location_manager,external.file.location.manager,model_external_file_location,base.group_system,1,1,1,1 access_external_file_location_user,external.file.location.user,model_external_file_location,base.group_user,1,0,0,0 +access_external_file_task_manager,external.file.task.manager,model_external_file_task,base.group_system,1,1,1,1 access_external_file_task_user,external.file.task.user,model_external_file_task,base.group_user,1,0,0,0 diff --git a/external_file_location/task.py b/external_file_location/task.py deleted file mode 100644 index 2e23ff421..000000000 --- a/external_file_location/task.py +++ /dev/null @@ -1,86 +0,0 @@ -# coding: utf-8 -# @ 2015 Valentin CHEMIERE @ Akretion -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from openerp import models, fields, api -from .helper import itersubclasses, get_erp_module, is_module_installed -from .abstract_task import AbstractTask - - -class Task(models.Model): - _name = 'external.file.task' - _description = 'Description' - - name = fields.Char(required=True) - method = fields.Selection(selection='_get_method', required=True, - help='procotol and trasmitting info') - method_type = fields.Char() - filename = fields.Char(help='File name which is imported') - filepath = fields.Char(help='Path to imported file') - location_id = fields.Many2one('external.file.location', string='Location', - 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') - md5_check = fields.Boolean(help='Control file integrity after import with' - ' a md5 file') - after_import = fields.Selection(selection='_get_action', - help='Action after import a file') - - def _get_action(self): - return [('move', 'Move'), ('delete', 'Delete')] - - def _get_method(self): - res = [] - for cls in itersubclasses(AbstractTask): - if not is_module_installed(self.env, get_erp_module(cls)): - continue - if cls._synchronize_type and ( - 'protocol' not in self._context or - cls._key == self._context['protocol']): - cls_info = (cls._key + '_' + cls._synchronize_type, - cls._name + ' ' + cls._synchronize_type) - res.append(cls_info) - return res - - @api.onchange('method') - def onchange_method(self): - if self.method: - if 'import' in self.method: - self.method_type = 'import' - elif 'export' in self.method: - self.method_type = 'export' - - @api.model - def _run(self, domain=None): - if not domain: - domain = [] - tasks = self.env['external.file.task'].search(domain) - tasks.run() - - @api.one - def run(self): - for cls in itersubclasses(AbstractTask): - if not is_module_installed(self.env, get_erp_module(cls)): - continue - cls_build = '%s_%s' % (cls._key, cls._synchronize_type) - if cls._synchronize_type and cls_build == self.method: - method_class = cls - config = { - 'host': self.location_id.address, - # ftplib does not support unicode - 'user': self.location_id.login.encode('utf-8'), - 'pwd': self.location_id.password.encode('utf-8'), - 'port': self.location_id.port, - 'allow_dir_creation': False, - 'file_name': self.filename, - 'path': self.filepath, - 'attachment_ids': self.attachment_ids, - 'task': self, - 'move_path': self.move_path, - 'after_import': self.after_import, - 'md5_check': self.md5_check, - } - conn = method_class(self.env, config) - conn.run() diff --git a/external_file_location/tasks/abstract_fs.py b/external_file_location/tasks/abstract_fs.py index d8798e7a0..03539f8d9 100644 --- a/external_file_location/tasks/abstract_fs.py +++ b/external_file_location/tasks/abstract_fs.py @@ -1,14 +1,52 @@ # coding: utf-8 # Copyright (C) 2014 initOS GmbH & Co. KG (). # @ 2015 Valentin CHEMIERE @ Akretion +# ©2016 @author Mourad EL HADJ MIMOUNE # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from ..abstract_task import AbstractTask import logging import os +import fnmatch +import datetime + +from openerp import tools + +from .abstract_task import AbstractTask + _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, + 'reduce': reduce, + 'map': map, + 'round': round, + }) +except ImportError: + _logger.warning("jinja2 not available, templating features will not work!") + + class AbstractFSTask(AbstractTask): _name = None @@ -26,7 +64,9 @@ class AbstractFSTask(AbstractTask): self.file_name = config.get('file_name', '') self.path = config.get('path') or '.' self.move_path = config.get('move_path', '') + self.new_name = config.get('new_name', '') self.after_import = config.get('after_import', False) + self.file_type = config.get('file_type', False) self.attachment_ids = config.get('attachment_ids', False) self.task = config.get('task', False) self.ext_hash = False @@ -67,12 +107,30 @@ class AbstractFSTask(AbstractTask): def _get_files(self, conn, path): process_files = [] files_list = conn.listdir(path) - for file in files_list: - if file == self.file_name: - source_name = self._source_name(self.path, self.file_name) - process_files.append((file, source_name)) + pattern = self.file_name + for file_name in fnmatch.filter(files_list, pattern): + source_name = self._source_name(self.path, file_name) + process_files.append((file_name, source_name)) return process_files + 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 + def _process_file(self, conn, file_to_process): if self.md5_check: self.ext_hash = self._get_hash(file_to_process[1], conn) @@ -81,17 +139,31 @@ class AbstractFSTask(AbstractTask): self.path, self.file_name, self.move_path) - - # Move/delete files only after all files have been processed. + move = False + rename = False + if self.after_import: + move = 'move' in self.after_import + rename = 'rename' in self.after_import + + # Move/rename/delete files only after all + # files have been processed. if self.after_import == 'delete': self._delete_file(conn, file_to_process[1]) - elif self.after_import == 'move': - if not conn.exists(self.move_path): + elif rename or move: + new_name = file_to_process[0] + if rename and self.new_name: + new_name_render = self._template_render( + self.new_name, att_id) + if new_name_render: + # Avoid space in file name + new_name = new_name_render.replace(' ', '_') + if self.move_path and not conn.exists(self.move_path): conn.makedir(self.move_path) + move_path = self.move_path if self.move_path else self.path self._move_file( conn, file_to_process[1], - self._source_name(self.move_path, file_to_process[0])) + self._source_name(move_path, new_name)) return att_id def _handle_existing_target(self, fs_conn, target_name, filedata): diff --git a/external_file_location/abstract_task.py b/external_file_location/tasks/abstract_task.py similarity index 89% rename from external_file_location/abstract_task.py rename to external_file_location/tasks/abstract_task.py index 82216787b..c40a6e9b7 100644 --- a/external_file_location/abstract_task.py +++ b/external_file_location/tasks/abstract_task.py @@ -22,6 +22,7 @@ class AbstractTask(object): 'datas_fname': filename, 'task_id': self.task and self.task.id or False, 'location_id': self.task and self.task.location_id.id or False, - 'external_hash': self.ext_hash + 'external_hash': self.ext_hash, + 'file_type': self.file_type, }) return ir_attachment_id diff --git a/external_file_location/tasks/sftp.py b/external_file_location/tasks/sftp.py index 9d57f2e92..6d4f6988a 100644 --- a/external_file_location/tasks/sftp.py +++ b/external_file_location/tasks/sftp.py @@ -27,7 +27,7 @@ class SftpImportTask(SftpTask): def run(self): connection_string = "{}:{}".format(self.host, self.port) - root = "/home/{}".format(self.user) + root = "/" att_ids = [] with sftpfs.SFTPFS(connection=connection_string, root_path=root, diff --git a/external_file_location/tests/test_sftp.py b/external_file_location/tests/test_sftp.py index 281251048..d08295cb6 100644 --- a/external_file_location/tests/test_sftp.py +++ b/external_file_location/tests/test_sftp.py @@ -1,15 +1,20 @@ # 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 StringIO import StringIO +from base64 import b64decode +import hashlib import openerp.tests.common as common from ..tasks.sftp import SftpImportTask from ..tasks.sftp import SftpExportTask from .mock_server import (server_mock) from .mock_server import MultiResponse -from StringIO import StringIO -from base64 import b64decode -import hashlib + + +_logger = logging.getLogger(__name__) class ContextualStringIO(StringIO): @@ -37,7 +42,7 @@ class TestNewSource(common.TransactionCase): 'password': 'test', 'host': 'test', 'port': 22, - 'attachment_ids': self.env['ir.attachment.metadata'].search([]) + 'attachment_ids': self.env['ir.attachment.metadata'].browse(False) } def test_00_sftp_import(self): @@ -80,6 +85,9 @@ class TestNewSource(common.TransactionCase): self.assertEqual(len(search_file), 1) self.assertEqual(b64decode(search_file[0].datas), 'import') self.assertEqual('remove', FakeSFTP[-1]['method']) + self.assertEqual( + './testfile', FakeSFTP[-1]['args'][0], + "Delete File must be './testfile'") def test_03_sftp_import_move(self): with server_mock( @@ -98,7 +106,58 @@ class TestNewSource(common.TransactionCase): self.assertEqual(b64decode(search_file[0].datas), 'import') self.assertEqual('rename', FakeSFTP[-1]['method']) - def test_04_sftp_import_md5(self): + def test_04_sftp_import_rename(self): + with server_mock( + {'exists': True, + 'makedir': True, + 'open': self.test_file, + 'listdir': ['testfile'], + 'rename': True + }) as FakeSFTP: + _logger.info("Test sftp rename file") + self.config.update({ + 'after_import': 'rename', + 'new_name': '${obj.name}.imported', + 'path': '/home', + }) + task = SftpImportTask(self.env, self.config) + task.run() + search_file = self.env['ir.attachment.metadata'].search( + (('name', '=', 'testfile'),)) + self.assertEqual(len(search_file), 1) + self.assertEqual(b64decode(search_file[0].datas), 'import') + self.assertEqual('rename', FakeSFTP[2]['method']) + self.assertEqual('/home/testfile.imported', + FakeSFTP[2]['args'][1], + "File not renamed") + + def test_05_sftp_import_move_rename(self): + with server_mock( + {'exists': True, + 'makedir': True, + 'open': self.test_file, + 'listdir': ['testfile'], + 'rename': True + }) as FakeSFTP: + _logger.info("Test sftp move and rename file") + self.config.update({ + 'after_import': 'rename', + 'new_name': '${obj.name}.imported', + 'path': '/home', + 'move_path': '/home/processed', + }) + task = SftpImportTask(self.env, self.config) + task.run() + search_file = self.env['ir.attachment.metadata'].search( + (('name', '=', 'testfile'),)) + self.assertEqual(len(search_file), 1) + self.assertEqual(b64decode(search_file[0].datas), 'import') + self.assertEqual('rename', FakeSFTP[3]['method']) + self.assertEqual('/home/processed/testfile.imported', + FakeSFTP[3]['args'][1], + "File not renamed and moved") + + def test_06_sftp_import_md5(self): md5_file = ContextualStringIO() md5_file.write(hashlib.md5('import').hexdigest()) md5_file.seek(0) diff --git a/external_file_location/views/attachment_view.xml b/external_file_location/views/attachment_view.xml new file mode 100644 index 000000000..c2806dbb3 --- /dev/null +++ b/external_file_location/views/attachment_view.xml @@ -0,0 +1,50 @@ + + + + + + ir.attachment.metadata + + + + + + + + + + + + + + ir.attachment.metadata + + + + + + + + + + + + + + ir.attachment.metadata + + + + + + + + + + + + + + + diff --git a/external_file_location/location_view.xml b/external_file_location/views/location_view.xml similarity index 95% rename from external_file_location/location_view.xml rename to external_file_location/views/location_view.xml index 5d2ee0a9c..527bcc848 100644 --- a/external_file_location/location_view.xml +++ b/external_file_location/views/location_view.xml @@ -42,7 +42,7 @@ external.file.location - + @@ -52,7 +52,7 @@ - Locations + File Locations ir.actions.act_window external.file.location form @@ -60,7 +60,7 @@ diff --git a/external_file_location/menu.xml b/external_file_location/views/menu.xml similarity index 100% rename from external_file_location/menu.xml rename to external_file_location/views/menu.xml diff --git a/external_file_location/task_view.xml b/external_file_location/views/task_view.xml similarity index 55% rename from external_file_location/task_view.xml rename to external_file_location/views/task_view.xml index 648bfdbd9..ae6042650 100644 --- a/external_file_location/task_view.xml +++ b/external_file_location/views/task_view.xml @@ -1,7 +1,10 @@ - + external.file.task @@ -20,9 +23,28 @@ - - - + + + + + + + + + + +