From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- addons/base_import_module/models/__init__.py | 5 + .../models/base_import_module.py | 46 +++++ addons/base_import_module/models/ir_module.py | 194 +++++++++++++++++++++ addons/base_import_module/models/ir_ui_view.py | 28 +++ 4 files changed, 273 insertions(+) create mode 100644 addons/base_import_module/models/__init__.py create mode 100644 addons/base_import_module/models/base_import_module.py create mode 100644 addons/base_import_module/models/ir_module.py create mode 100644 addons/base_import_module/models/ir_ui_view.py (limited to 'addons/base_import_module/models') diff --git a/addons/base_import_module/models/__init__.py b/addons/base_import_module/models/__init__.py new file mode 100644 index 00000000..429bd00b --- /dev/null +++ b/addons/base_import_module/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +from . import base_import_module +from . import ir_module +from . import ir_ui_view diff --git a/addons/base_import_module/models/base_import_module.py b/addons/base_import_module/models/base_import_module.py new file mode 100644 index 00000000..985f3e45 --- /dev/null +++ b/addons/base_import_module/models/base_import_module.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +import base64 +from io import BytesIO +from odoo import api, fields, models + + +class BaseImportModule(models.TransientModel): + """ Import Module """ + _name = "base.import.module" + _description = "Import Module" + + module_file = fields.Binary(string='Module .ZIP file', required=True, attachment=False) + state = fields.Selection([('init', 'init'), ('done', 'done')], string='Status', readonly=True, default='init') + import_message = fields.Text() + force = fields.Boolean(string='Force init', help="Force init mode even if installed. (will update `noupdate='1'` records)") + + def import_module(self): + self.ensure_one() + IrModule = self.env['ir.module.module'] + zip_data = base64.decodebytes(self.module_file) + fp = BytesIO() + fp.write(zip_data) + res = IrModule.import_zipfile(fp, force=self.force) + self.write({'state': 'done', 'import_message': res[0]}) + context = dict(self.env.context, module_name=res[1]) + # Return wizard otherwise it will close wizard and will not show result message to user. + return { + 'name': 'Import Module', + 'view_mode': 'form', + 'target': 'new', + 'res_id': self.id, + 'res_model': 'base.import.module', + 'type': 'ir.actions.act_window', + 'context': context, + } + + def action_module_open(self): + self.ensure_one() + return { + 'domain': [('name', 'in', self.env.context.get('module_name', []))], + 'name': 'Modules', + 'view_mode': 'tree,form', + 'res_model': 'ir.module.module', + 'view_id': False, + 'type': 'ir.actions.act_window', + } diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py new file mode 100644 index 00000000..f245ead6 --- /dev/null +++ b/addons/base_import_module/models/ir_module.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +import ast +import base64 +import logging +import lxml +import os +import sys +import tempfile +import zipfile +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 + +_logger = logging.getLogger(__name__) + +MAX_FILE_SIZE = 100 * 1024 * 1024 # in megabytes + + +class IrModule(models.Model): + _inherit = "ir.module.module" + + imported = fields.Boolean(string="Imported Module") + + @api.depends('name') + def _get_latest_version(self): + imported_modules = self.filtered(lambda m: m.imported and m.latest_version) + for module in imported_modules: + module.installed_version = module.latest_version + super(IrModule, self - imported_modules)._get_latest_version() + + def _import_module(self, module, path, force=False): + known_mods = self.search([]) + 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) + 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) + + if unmet_dependencies: + if (unmet_dependencies == set(['web_studio']) and + _is_studio_custom(path)): + err = _("Studio customizations require Studio") + else: + err = _("Unmet module dependencies: \n\n - %s") % '\n - '.join( + known_mods.filtered(lambda mod: mod.name in unmet_dependencies).mapped('shortdesc') + ) + raise UserError(err) + elif 'web_studio' not in installed_mods and _is_studio_custom(path): + raise UserError(_("Studio customizations require the Odoo Studio app.")) + + mod = known_mods_names.get(module) + if mod: + mod.write(dict(state='installed', **values)) + mode = 'update' if not force else 'init' + else: + assert terp.get('installable', True), "Module not installable" + self.create(dict(name=module, state='installed', imported=True, **values)) + mode = 'init' + + for kind in ['data', 'init_xml', 'update_xml']: + for filename in terp[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) + continue + _logger.info("module %s: loading %s", module, filename) + noupdate = False + if ext == '.csv' and kind in ('init', 'init_xml'): + noupdate = True + pathname = opj(path, filename) + idref = {} + convert_file(self.env.cr, module, filename, idref, mode=mode, noupdate=noupdate, kind=kind, pathname=pathname) + + path_static = opj(path, 'static') + IrAttachment = self.env['ir.attachment'] + if os.path.isdir(path_static): + 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: + data = base64.b64encode(fp.read()) + url_path = '/{}{}'.format(module, full_path.split(path)[1].replace(os.path.sep, '/')) + if not isinstance(url_path, str): + url_path = url_path.decode(sys.getfilesystemencoding()) + filename = os.path.split(url_path)[1] + values = dict( + name=filename, + url=url_path, + res_model='ir.ui.view', + type='binary', + datas=data, + ) + attachment = IrAttachment.search([('url', '=', url_path), ('type', '=', 'binary'), ('res_model', '=', 'ir.ui.view')]) + if attachment: + attachment.write(values) + else: + IrAttachment.create(values) + + return True + + @api.model + def import_zipfile(self, module_file, force=False): + if not module_file: + raise Exception(_("No file sent.")) + if not zipfile.is_zipfile(module_file): + raise UserError(_('Only zip files are supported.')) + + success = [] + errors = dict() + module_names = [] + with zipfile.ZipFile(module_file, "r") as z: + for zf in z.filelist: + if zf.file_size > MAX_FILE_SIZE: + 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) + 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) + try: + # assert mod_name.startswith('theme_') + path = opj(module_dir, mod_name) + if self._import_module(mod_name, path, force=force): + success.append(mod_name) + except Exception as e: + _logger.exception('Error while importing module') + errors[mod_name] = exception_to_unicode(e) + finally: + odoo.addons.__path__.remove(module_dir) + 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)) + return '\n'.join(r), module_names + + def module_uninstall(self): + # Delete an ir_module_module record completely if it was an imported + # one. The rationale behind this is that an imported module *cannot* be + # reinstalled anyway, as it requires the data files. Any attempt to + # install it again will simply fail without trace. + # /!\ modules_to_delete must be calculated before calling super().module_uninstall(), + # because when uninstalling `base_import_module` the `imported` column will no longer be + # in the database but we'll still have an old registry that runs this code. + modules_to_delete = self.filtered('imported') + res = super().module_uninstall() + if modules_to_delete: + _logger.info("deleting imported modules upon uninstallation: %s", + ", ".join(modules_to_delete.mapped('name'))) + modules_to_delete.unlink() + return res + + +def _is_studio_custom(path): + """ + Checks the to-be-imported records to see if there are any references to + studio, which would mean that the module was created using studio + + Returns True if any of the records contains a context with the key + studio in it, False if none of the records do + """ + filepaths = [] + for level in os.walk(path): + filepaths += [os.path.join(level[0], fn) for fn in level[2]] + filepaths = [fp for fp in filepaths if fp.lower().endswith('.xml')] + + for fp in filepaths: + root = lxml.etree.parse(fp).getroot() + + for record in root: + # there might not be a context if it's a non-studio module + try: + # ast.literal_eval is like eval(), but safer + # context is a string representing a python dict + ctx = ast.literal_eval(record.get('context')) + # there are no cases in which studio is false + # so just checking for its existence is enough + if ctx and ctx.get('studio'): + return True + except Exception: + continue + return False diff --git a/addons/base_import_module/models/ir_ui_view.py b/addons/base_import_module/models/ir_ui_view.py new file mode 100644 index 00000000..adaa6651 --- /dev/null +++ b/addons/base_import_module/models/ir_ui_view.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from operator import itemgetter +from odoo import api, models + +class IrUiView(models.Model): + _inherit = 'ir.ui.view' + + @api.model + def _validate_custom_views(self, model): + # views from imported modules should be considered as custom views + result = super(IrUiView, self)._validate_custom_views(model) + + self._cr.execute(""" + SELECT max(v.id) + FROM ir_ui_view v + LEFT JOIN ir_model_data md ON (md.model = 'ir.ui.view' AND md.res_id = v.id) + LEFT JOIN ir_module_module m ON (m.name = md.module) + WHERE m.imported = true + AND v.model = %s + AND v.active = true + GROUP BY coalesce(v.inherit_id, v.id) + """, [model]) + + ids = (row[0] for row in self._cr.fetchall()) + views = self.with_context(load_all_views=True).browse(ids) + return views._check_xml() and result -- cgit v1.2.3