diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py index 317b059b911d09af18714eb07ea7c2bf3e00e01c..82feff6f145aabe33bc8be57973570acff74e9d3 100644 --- a/addons/base_import_module/models/ir_module.py +++ b/addons/base_import_module/models/ir_module.py @@ -7,18 +7,28 @@ import os import sys import tempfile import zipfile +from collections import defaultdict from os.path import join as opj import odoo from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.modules import load_information_from_description_file -from odoo.tools import convert_file, exception_to_unicode +from odoo.modules.module import MANIFEST_NAMES +from odoo.tools import convert_csv_import, convert_sql_import, convert_xml_import, exception_to_unicode _logger = logging.getLogger(__name__) MAX_FILE_SIZE = 100 * 1024 * 1024 # in megabytes +__import_paths__ = {} + +def _file_open(env, path): + path = os.path.normcase(os.path.normpath(path)) + import_path = __import_paths__.get(env) + if import_path and path.startswith(import_path): + return open(path, 'rb') + return odoo.tools.file_open(path, 'rb') + class IrModule(models.Model): _inherit = "ir.module.module" @@ -41,14 +51,18 @@ class IrModule(models.Model): known_mods_names = {m.name: m for m in known_mods} installed_mods = [m.name for m in known_mods if m.state == 'installed'] - terp = load_information_from_description_file(module, mod_path=path) + terp = {} + manifest_path = next((opj(path, name) for name in MANIFEST_NAMES if os.path.exists(opj(path, name))), None) + if manifest_path: + with _file_open(self.env, manifest_path) as f: + terp.update(ast.literal_eval(f.read().decode())) if not terp: return False values = self.get_values_from_terp(terp) if 'version' in terp: values['latest_version'] = terp['version'] - unmet_dependencies = set(terp['depends']).difference(installed_mods) + unmet_dependencies = set(terp.get('depends', [])).difference(installed_mods) if unmet_dependencies: if (unmet_dependencies == set(['web_studio']) and @@ -72,7 +86,7 @@ class IrModule(models.Model): mode = 'init' for kind in ['data', 'init_xml', 'update_xml']: - for filename in terp[kind]: + for filename in terp.get(kind, []): ext = os.path.splitext(filename)[1].lower() if ext not in ('.xml', '.csv', '.sql'): _logger.info("module %s: skip unsupported file %s", module, filename) @@ -83,7 +97,13 @@ class IrModule(models.Model): noupdate = True pathname = opj(path, filename) idref = {} - convert_file(self.env.cr, module, filename, idref, mode=mode, noupdate=noupdate, kind=kind, pathname=pathname) + with _file_open(self.env, pathname) as fp: + if ext == '.csv': + convert_csv_import(self.env.cr, module, pathname, fp.read(), idref, mode, noupdate) + elif ext == '.sql': + convert_sql_import(self.env.cr, fp) + elif ext == '.xml': + convert_xml_import(self.env.cr, module, fp, idref, mode, noupdate) path_static = opj(path, 'static') IrAttachment = self.env['ir.attachment'] @@ -91,7 +111,7 @@ class IrModule(models.Model): for root, dirs, files in os.walk(path_static): for static_file in files: full_path = opj(root, static_file) - with open(full_path, 'rb') as fp: + with _file_open(self.env, full_path) as fp: data = base64.b64encode(fp.read()) url_path = '/{}{}'.format(module, full_path.split(path)[1].replace(os.path.sep, '/')) if not isinstance(url_path, str): @@ -173,10 +193,35 @@ class IrModule(models.Model): raise UserError(_("File '%s' exceed maximum allowed file size", zf.filename)) with tempfile.TemporaryDirectory() as module_dir: - import odoo.modules.module as module try: - odoo.addons.__path__.append(module_dir) - z.extractall(module_dir) + __import_paths__[self.env] = module_dir + manifest_files = [ + file + for file in z.filelist + if file.filename.count('/') == 1 + and file.filename.split('/')[1] in MANIFEST_NAMES + ] + module_data_files = defaultdict(list) + for manifest in manifest_files: + manifest_path = z.extract(manifest, module_dir) + mod_name = manifest.filename.split('/')[0] + try: + with _file_open(self.env, manifest_path) as f: + terp = ast.literal_eval(f.read().decode()) + except Exception: + continue + for filename in terp.get('data', []) + terp.get('init_xml', []) + terp.get('update_xml', []): + if os.path.splitext(filename)[1].lower() not in ('.xml', '.csv', '.sql'): + continue + module_data_files[mod_name].append('%s/%s' % (mod_name, filename)) + for file in z.filelist: + filename = file.filename + mod_name = filename.split('/')[0] + is_data_file = filename in module_data_files[mod_name] + is_static = filename.startswith('%s/static' % mod_name) + if is_data_file or is_static: + z.extract(file, module_dir) + dirs = [d for d in os.listdir(module_dir) if os.path.isdir(opj(module_dir, d))] for mod_name in dirs: module_names.append(mod_name) @@ -189,7 +234,7 @@ class IrModule(models.Model): _logger.exception('Error while importing module') errors[mod_name] = exception_to_unicode(e) finally: - odoo.addons.__path__.remove(module_dir) + __import_paths__.pop(self.env) r = ["Successfully imported module '%s'" % mod for mod in success] for mod, error in errors.items(): r.append("Error while importing module '%s'.\n\n %s \n Make sure those modules are installed and try again." % (mod, error)) diff --git a/addons/base_import_module/tests/__init__.py b/addons/base_import_module/tests/__init__.py index 840183061c67772de1d8da08e89808ed4b875991..0a0c7d51b6af2fa4ab10dd311b08c55f83d85f16 100644 --- a/addons/base_import_module/tests/__init__.py +++ b/addons/base_import_module/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_import_module from . import test_cloc +from . import test_import_module diff --git a/addons/base_import_module/tests/test_import_module.py b/addons/base_import_module/tests/test_import_module.py index 14d052038671bd3340035a4f37ce0b30a0c29be4..d9c1710f40ba54710f109360884495a8b03c4470 100644 --- a/addons/base_import_module/tests/test_import_module.py +++ b/addons/base_import_module/tests/test_import_module.py @@ -1,15 +1,203 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 import json +import os +import tempfile + from io import BytesIO from zipfile import ZipFile import odoo.tests from odoo.tests import new_test_user + +from unittest.mock import patch + +from odoo.addons import __path__ as __addons_path__ +from odoo.tools import mute_logger + + @odoo.tests.tagged('post_install', '-at_install') class TestImportModule(odoo.tests.TransactionCase): + def import_zipfile(self, files): + archive = BytesIO() + with ZipFile(archive, 'w') as zipf: + for path, data in files: + zipf.writestr(path, data) + return self.env['ir.module.module'].import_zipfile(archive) + + def test_import_zip(self): + """Assert the behaviors expected by the module import feature using a ZIP archive""" + files = [ + ('foo/__manifest__.py', b"{'data': ['data.xml', 'res.partner.csv', 'data.sql']}"), + ('foo/data.xml', b""" + <data> + <record id="foo" model="res.partner"> + <field name="name">foo</field> + </record> + </data> + """), + ('foo/res.partner.csv', + b'"id","name"\n' \ + b'bar,bar' + ), + ('foo/data.sql', b"INSERT INTO res_partner (active, name) VALUES (true, 'baz');"), + ('foo/static/css/style.css', b".foo{color: black;}"), + ('foo/static/js/foo.js', b"console.log('foo')"), + ('bar/__manifest__.py', b"{'data': ['data.xml']}"), + ('bar/data.xml', b""" + <data> + <record id="foo" model="res.country"> + <field name="name">foo</field> + </record> + </data> + """), + ] + self.import_zipfile(files) + self.assertEqual(self.env.ref('foo.foo')._name, 'res.partner') + self.assertEqual(self.env.ref('foo.foo').name, 'foo') + self.assertEqual(self.env.ref('foo.bar')._name, 'res.partner') + self.assertEqual(self.env.ref('foo.bar').name, 'bar') + self.assertEqual(self.env['res.partner'].search_count([('name', '=', 'baz')]), 1) + + self.assertEqual(self.env.ref('bar.foo')._name, 'res.country') + self.assertEqual(self.env.ref('bar.foo').name, 'foo') + + for path, data in files: + if path.split('/')[1] == 'static': + static_attachment = self.env['ir.attachment'].search([('url', '=', '/%s' % path)]) + self.assertEqual(static_attachment.name, os.path.basename(path)) + self.assertEqual(static_attachment.datas, base64.b64encode(data)) + + def test_import_zip_invalid_manifest(self): + """Assert the expected behavior when import a ZIP module with an invalid manifest""" + files = [ + ('foo/__manifest__.py', b"foo") + ] + with mute_logger("odoo.addons.base_import_module.models.ir_module"): + result = self.import_zipfile(files) + self.assertIn("Error while importing module 'foo'", result[0]) + + def test_import_zip_data_not_in_manifest(self): + """Assert a data file not mentioned in the manifest is not imported""" + files = [ + ('foo/__manifest__.py', b"{'data': ['foo.xml']}"), + ('foo/foo.xml', b""" + <data> + <record id="foo" model="res.partner"> + <field name="name">foo</field> + </record> + </data> + """), + ('foo/bar.xml', b""" + <data> + <record id="bar" model="res.partner"> + <field name="name">bar</field> + </record> + </data> + """), + ] + self.import_zipfile(files) + self.assertEqual(self.env.ref('foo.foo').name, 'foo') + self.assertFalse(self.env.ref('foo.bar', raise_if_not_found=False)) + + def test_import_zip_ignore_unexpected_data_extension(self): + """Assert data files using an unexpected extensions are correctly ignored""" + files = [ + ('foo/__manifest__.py', b"{'data': ['res.partner.xls']}"), + ('foo/res.partner.xls', + b'"id","name"\n' \ + b'foo,foo' + ), + ] + with self.assertLogs('odoo.addons.base_import_module.models.ir_module') as log_catcher: + self.import_zipfile(files) + self.assertEqual(len(log_catcher.output), 1) + self.assertIn('module foo: skip unsupported file res.partner.xls', log_catcher.output[0]) + self.assertFalse(self.env.ref('foo.foo', raise_if_not_found=False)) + + def test_import_zip_extract_only_useful(self): + """Assert only the data and static files are extracted of the ZIP archive during the module import""" + files = [ + ('foo/__manifest__.py', b"{'data': ['data.xml', 'res.partner.xls']}"), + ('foo/data.xml', b""" + <data> + <record id="foo" model="res.partner"> + <field name="name">foo</field> + </record> + </data> + """), + ('foo/res.partner.xls', + b'"id","name"\n' \ + b'foo,foo' + ), + ('foo/static/css/style.css', b".foo{color: black;}"), + ('foo/foo.py', b"foo = 42"), + ] + extracted_files = [] + addons_path = [] + origin_import_module = type(self.env['ir.module.module'])._import_module + def _import_module(self, *args, **kwargs): + _module, path = args + for root, _dirs, files in os.walk(path): + for file in files: + extracted_files.append(os.path.relpath(os.path.join(root, file), path)) + addons_path.extend(__addons_path__) + return origin_import_module(self, *args, **kwargs) + with patch.object(type(self.env['ir.module.module']), '_import_module', _import_module): + self.import_zipfile(files) + self.assertIn( + '__manifest__.py', extracted_files, + "__manifest__.py must be in the extracted files") + self.assertIn( + 'data.xml', extracted_files, + "data.xml must be in the extracted files as its in the manifest's data") + self.assertIn( + 'static/css/style.css', extracted_files, + "style.css must be in the extracted files as its in the static folder") + self.assertNotIn( + 'res.partner.xls', extracted_files, + "res.partner.xls must not be in the extracted files as it uses an unsupported extension of data file") + self.assertNotIn( + 'foo.py', extracted_files, + "foo.py must not be in the extracted files as its not the manifest's data") + self.assertFalse( + set(addons_path).difference(__addons_path__), + 'No directory must be added in the addons path during import') + + def test_import_module_addons_path(self): + """Assert it's possible to import a module using directly `_import_module` without zip from the addons path""" + files = [ + ('foo/__manifest__.py', b"{'data': ['data.xml']}"), + ('foo/data.xml', b""" + <data> + <record id="foo" model="res.partner"> + <field name="name">foo</field> + </record> + </data> + """), + ('foo/static/css/style.css', b".foo{color: black;}"), + ] + with tempfile.TemporaryDirectory() as module_dir: + for path, data in files: + os.makedirs(os.path.join(module_dir, os.path.dirname(path)), exist_ok=True) + with open(os.path.join(module_dir, path), 'wb') as fp: + fp.write(data) + try: + __addons_path__.append(module_dir) + self.env['ir.module.module']._import_module('foo', os.path.join(module_dir, 'foo')) + finally: + __addons_path__.remove(module_dir) + + self.assertEqual(self.env.ref('foo.foo')._name, 'res.partner') + self.assertEqual(self.env.ref('foo.foo').name, 'foo') + static_path, static_data = files[2] + static_attachment = self.env['ir.attachment'].search([('url', '=', '/%s' % static_path)]) + self.assertEqual(static_attachment.name, os.path.basename(static_path)) + self.assertEqual(static_attachment.datas, base64.b64encode(static_data)) + def test_import_and_uninstall_module(self): bundle = 'web.assets_backend' path = '/test_module/static/src/js/test.js'