[IMP] base_import_module: add industry modules availability

We now have the possbility to publish modules that only contains XML on
apps.odoo.com and we want to display in the apps of the database to ease
the installation.

TASK-3186716

closes odoo/odoo#140245

Signed-off-by: Pierre Masereel (pim) <pim@odoo.com>
This commit is contained in:
Pierre Masereel
2023-11-02 16:57:50 +00:00
parent 9b06f097b8
commit 234590f3db
9 changed files with 282 additions and 34 deletions
+3 -1
View File
@@ -12,9 +12,11 @@ for customization purpose.
'category': 'Hidden/Tools',
'depends': ['web'],
'installable': True,
'auto_install': True,
'data': [
'security/ir.model.access.csv',
'views/base_import_module_view.xml'
'views/base_import_module_view.xml',
'views/ir_module_views.xml',
],
'license': 'LGPL-3',
}
@@ -17,7 +17,7 @@ class ImportModule(Controller):
request.session.authenticate(request.db, login, password)
# request.uid is None in case of MFA
if request.uid and request.env.user._is_admin():
return request.env['ir.module.module'].import_zipfile(mod_file, force=force == '1')[0]
return request.env['ir.module.module']._import_zipfile(mod_file, force=force == '1')[0]
raise AccessError(_("Only administrators can upload a module"))
except Exception as e:
return Response(response=str(e), status=500)
@@ -15,6 +15,19 @@ msgstr ""
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: base_import_module
#: model_terms:ir.ui.view,arch_db:base_import_module.module_view_kanban_apps_inherit
msgid "Activate"
msgstr ""
#. module: base_import_module
#. odoo-python
#: code:addons/base_import_module/models/ir_module.py:0
#: model_terms:ir.ui.view,arch_db:base_import_module.view_module_filter_apps_inherit
#, python-format
msgid "Apps"
msgstr ""
#. module: base_import_module
#: model_terms:ir.ui.view,arch_db:base_import_module.view_base_module_import
msgid "Cancel"
@@ -25,6 +38,20 @@ msgstr ""
msgid "Close"
msgstr ""
#. module: base_import_module
#. odoo-python
#: code:addons/base_import_module/models/ir_module.py:0
#, python-format
msgid "Connection to %s failed The list of industry modules cannot be fetched"
msgstr ""
#. module: base_import_module
#. odoo-python
#: code:addons/base_import_module/models/ir_module.py:0
#, python-format
msgid "Connection to %s failed, the module %s cannot be downloaded."
msgstr ""
#. module: base_import_module
#. odoo-python
#: code:addons/base_import_module/controllers/main.py:0
@@ -82,6 +109,11 @@ msgstr ""
msgid "Import Module"
msgstr ""
#. module: base_import_module
#: model_terms:ir.ui.view,arch_db:base_import_module.view_base_module_import
msgid "Import demo data"
msgstr ""
#. module: base_import_module
#: model:ir.model.fields,field_description:base_import_module.field_base_import_module__with_demo
msgid "Import demo data of module"
@@ -192,14 +224,31 @@ msgstr ""
msgid "Studio customizations require the Odoo Studio app."
msgstr ""
#. module: base_import_module
#. odoo-python
#: code:addons/base_import_module/models/ir_module.py:0
#, python-format
msgid "The following modules will be also installed:\n"
msgstr ""
#. module: base_import_module
#. odoo-python
#: code:addons/base_import_module/models/ir_module.py:0
#, python-format
msgid ""
"Unmet module dependencies: \n"
"\n"
" - %s"
"The list of industry applications cannot be fetched. Please try again later"
msgstr ""
#. module: base_import_module
#. odoo-python
#: code:addons/base_import_module/models/ir_module.py:0
#, python-format
msgid "The module %s cannot be downloaded"
msgstr ""
#. module: base_import_module
#: model_terms:ir.ui.view,arch_db:base_import_module.module_view_kanban_apps_inherit
msgid "Upgrade"
msgstr ""
#. module: base_import_module
@@ -22,18 +22,12 @@ class BaseImportModule(models.TransientModel):
zip_data = base64.decodebytes(self.module_file)
fp = BytesIO()
fp.write(zip_data)
res = IrModule.import_zipfile(fp, force=self.force)
res = IrModule._import_zipfile(fp, force=self.force, with_demo=self.with_demo)
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,
'type': 'ir.actions.act_url',
'target': 'self',
'url': '/web',
}
def action_module_open(self):
+144 -10
View File
@@ -4,20 +4,24 @@ import base64
import logging
import lxml
import os
import requests
import sys
import tempfile
import zipfile
from collections import defaultdict
from io import BytesIO
from os.path import join as opj
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.exceptions import AccessDenied, UserError
from odoo.modules.module import MANIFEST_NAMES
from odoo.release import major_version
from odoo.tools import convert_csv_import, convert_sql_import, convert_xml_import, exception_to_unicode
from odoo.tools import file_open, file_open_temporary_directory
_logger = logging.getLogger(__name__)
APPS_URL = "https://apps.odoo.com"
MAX_FILE_SIZE = 100 * 1024 * 1024 # in megabytes
@@ -41,7 +45,7 @@ class IrModule(models.Model):
module.installed_version = module.latest_version
super(IrModule, self - imported_modules)._get_latest_version()
def _import_module(self, module, path, force=False):
def _import_module(self, module, path, force=False, with_demo=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']
@@ -68,10 +72,8 @@ class IrModule(models.Model):
_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)
to_install = known_mods.filtered(lambda mod: mod.name in unmet_dependencies)
to_install.button_immediate_install()
elif 'web_studio' not in installed_mods and _is_studio_custom(path):
raise UserError(_("Studio customizations require the Odoo Studio app."))
@@ -84,7 +86,10 @@ class IrModule(models.Model):
self.create(dict(name=module, state='installed', imported=True, **values))
mode = 'init'
for kind in ['data', 'init_xml', 'update_xml']:
kind_of_files = ['data', 'init_xml', 'update_xml']
if with_demo:
kind_of_files.append('demo')
for kind in kind_of_files:
for filename in terp.get(kind, []):
ext = os.path.splitext(filename)[1].lower()
if ext not in ('.xml', '.csv', '.sql'):
@@ -177,7 +182,7 @@ class IrModule(models.Model):
return True
@api.model
def import_zipfile(self, module_file, force=False):
def _import_zipfile(self, module_file, force=False, with_demo=False):
if not module_file:
raise Exception(_("No file sent."))
if not zipfile.is_zipfile(module_file):
@@ -207,7 +212,10 @@ class IrModule(models.Model):
terp = ast.literal_eval(f.read().decode())
except Exception:
continue
for filename in terp.get('data', []) + terp.get('init_xml', []) + terp.get('update_xml', []):
files_to_import = terp.get('data', []) + terp.get('init_xml', []) + terp.get('update_xml', [])
if with_demo:
files_to_import += terp.get('demo', [])
for filename in files_to_import:
if os.path.splitext(filename)[1].lower() not in ('.xml', '.csv', '.sql'):
continue
module_data_files[mod_name].append('%s/%s' % (mod_name, filename))
@@ -225,7 +233,7 @@ class IrModule(models.Model):
try:
# assert mod_name.startswith('theme_')
path = opj(module_dir, mod_name)
if self._import_module(mod_name, path, force=force):
if self._import_module(mod_name, path, force=force, with_demo=with_demo):
success.append(mod_name)
except Exception as e:
_logger.exception('Error while importing module')
@@ -258,6 +266,132 @@ class IrModule(models.Model):
modules_to_delete.unlink()
return res
@api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
res = super().web_search_read(domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit)
if any(dom[0] == 'module_type' and dom[-1] == 'industries' for dom in domain):
fields_name = list(specification.keys())
modules_list = self._get_modules_from_apps(fields_name, 'industries', False)
res['length'] += len(modules_list)
res['records'].extend(modules_list)
return res
def more_info(self):
return {
'name': _('Apps'),
'type': 'ir.actions.act_window',
'res_model': 'ir.module.module',
'view_mode': 'form',
'res_id': self.id,
'context': self.env.context,
}
def web_read(self, specification):
fields = list(specification.keys())
module_type = self.env.context.get('module_type', 'official')
if module_type != 'official':
modules_list = self._get_modules_from_apps(fields, module_type, self.env.context.get('module_name'))
return modules_list
else:
return super().web_read(specification)
@api.model
def _get_modules_from_apps(self, fields, module_type, module_name):
payload = {
'series': major_version,
'module_fields': fields,
'module_type':module_type,
'module_name': module_name
}
try:
resp = requests.post(
f"{APPS_URL}/loempia/listdatamodules",
json={'params': payload},
timeout=5.0,
)
if resp.status_code == 200:
modules_list = resp.json().get('result', [])
for mod in modules_list:
mod['id'] = -1
module_name = mod.get('name', module_name)
if 'icon' in fields:
mod['icon'] = f"{APPS_URL}{mod['icon']}"
if 'state' in fields:
existing_mod = self.search([('name', '=', module_name), ('state', '=', 'installed')])
if existing_mod:
mod['state'] = 'installed'
else:
mod['state'] = 'uninstalled'
if 'module_type' in fields:
mod['module_type'] = module_type
if 'website' in fields:
mod['website'] = f"{APPS_URL}/apps/modules/{major_version}/{module_name}/"
return modules_list
resp.raise_for_status()
except requests.exceptions.HTTPError:
raise UserError(_('The list of industry applications cannot be fetched. Please try again later'))
except requests.exceptions.ConnectionError:
raise UserError(_('Connection to %s failed The list of industry modules cannot be fetched') % APPS_URL)
def button_immediate_install_app(self):
if not self.env.is_admin():
raise AccessDenied()
module_name = self.env.context.get('module_name')
try:
resp = requests.get(
f"{APPS_URL}/loempia/download/data_app/{module_name}/{major_version}",
timeout=5.0,
)
if resp.status_code == 200:
import_module = self.env['base.import.module'].create({
'module_file': base64.b64encode(resp.content),
'state': 'init',
'modules_dependencies': self._get_missing_dependencies(resp.content)
})
return {
'name': 'Install an App',
'view_mode': 'form',
'target': 'new',
'res_id': import_module.id,
'res_model': 'base.import.module',
'type': 'ir.actions.act_window',
'context': {'data_module': True}
}
resp.raise_for_status()
except requests.exceptions.HTTPError:
raise UserError(_('The module %s cannot be downloaded') % module_name)
except requests.exceptions.ConnectionError:
raise UserError(_('Connection to %s failed, the module %s cannot be downloaded.', APPS_URL, module_name))
@api.model
def _get_missing_dependencies(self, zip_data):
dependencies_to_install = self.env['ir.module.module']
known_mods = self.search([])
installed_mods = [m.name for m in known_mods if m.state == 'installed']
with zipfile.ZipFile(BytesIO(zip_data), "r") as z:
manifest_files = [
file
for file in z.filelist
if file.filename.count('/') == 1
and file.filename.split('/')[1] in MANIFEST_NAMES
]
for manifest_file in manifest_files:
if manifest_file.file_size > MAX_FILE_SIZE:
raise UserError(_("File '%s' exceed maximum allowed file size", manifest_file.filename))
try:
with z.open(manifest_file) as manifest:
terp = ast.literal_eval(manifest.read().decode())
except Exception:
continue
unmet_dependencies = set(terp.get('depends', [])).difference(installed_mods)
dependencies_to_install |= known_mods.filtered(lambda m: m.name in unmet_dependencies)
description = ''
if dependencies_to_install:
description = _('The following modules will be also installed:\n')
for mod in dependencies_to_install:
description += "- " + mod.shortdesc + "\n"
return description
def _is_studio_custom(path):
"""
+2 -2
View File
@@ -146,7 +146,7 @@ class TestClocFields(test_cloc.TestClocCustomization):
archive.writestr('test_imported_module/static/src/js/test.xml', VALID_XML)
# Import test module
self.env['ir.module.module'].import_zipfile(stream)
self.env['ir.module.module']._import_zipfile(stream)
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('test_imported_module', 0), 35)
@@ -201,7 +201,7 @@ class TestClocFields(test_cloc.TestClocCustomization):
]
# Import test module
self.env['ir.module.module'].import_zipfile(stream)
self.env['ir.module.module']._import_zipfile(stream)
# Create exclude xml_id
for name in id_names:
rec = self.env.ref(f'test_imported_module.{name}')
@@ -25,7 +25,7 @@ class TestImportModule(odoo.tests.TransactionCase):
with ZipFile(archive, 'w') as zipf:
for path, data in files:
zipf.writestr(path, data)
return self.env['ir.module.module'].import_zipfile(archive)
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"""
@@ -187,7 +187,7 @@ class TestImportModule(odoo.tests.TransactionCase):
archive.writestr('test_module/static/src/js/test.js', "console.log('AAA');")
# Import test module
self.env['ir.module.module'].import_zipfile(stream)
self.env['ir.module.module']._import_zipfile(stream)
attachment = self.env['ir.attachment'].search([('url', '=', path)])
self.assertEqual(attachment.name, 'test.js')
@@ -240,7 +240,7 @@ class TestImportModule(odoo.tests.TransactionCase):
archive.writestr(path, "console.log('AAA');")
# Import test module
self.env['ir.module.module'].with_user(self.test_user).import_zipfile(stream)
self.env['ir.module.module'].with_user(self.test_user)._import_zipfile(stream)
attachment = self.env['ir.attachment'].search([('url', '=', f'/{path}')])
self.assertEqual(attachment.name, 'test.js')
@@ -264,7 +264,7 @@ class TestImportModule(odoo.tests.TransactionCase):
archive.writestr(path, "console.log('BBB');")
# Import test module
self.env['ir.module.module'].with_user(self.test_user).import_zipfile(stream)
self.env['ir.module.module'].with_user(self.test_user)._import_zipfile(stream)
attachment = self.env['ir.attachment'].search([('url', '=', f'/{path}')])
self.assertEqual(attachment.name, 'test.js')
@@ -7,10 +7,14 @@
<field name="arch" type="xml">
<form string="Install the application">
<field name="state" invisible="1"/>
<p class="alert alert-warning" role="alert" invisible="state == 'done'">Note: you can only import data modules (.xml files and static assets)</p>
<p class="alert alert-warning" role="alert" invisible="state == 'done' or context.get('data_module')">Note: you can only import data modules (.xml files and static assets)</p>
<group col="4">
<field name="modules_dependencies" readonly="1" colspan="4" nolabel="1" invisible="state == 'done'"/>
</group>
<group invisible="state != 'init'" col="4">
<field name="module_file" string="Module file (.zip)" colspan="4" options="{'accepted_file_extensions': '.zip'}"/>
<field name="force" colspan="4" groups="base.group_no_one"/>
<field name="module_file" string="Module file (.zip)" colspan="4" options="{'accepted_file_extensions': '.zip'}" invisible="context.get('data_module')"/>
<field name="force" groups="base.group_no_one"/>
<field name="with_demo" string="Import demo data"/>
</group>
<group invisible="state != 'done'" col="4">
<field name="import_message" colspan="4" nolabel="1" readonly="1"/>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record model="ir.ui.view" id="module_view_kanban_apps_inherit">
<field name="name">Apps Kanban Data Modules</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='application']" position="after">
<field name="module_type"/>
</xpath>
<xpath expr="//button[@name='button_immediate_install']" position="after">
<button type="object" class="btn btn-primary btn-sm" name="button_immediate_install_app" invisible="state != 'uninstalled' or module_type == 'official'" groups="base.group_system" context="{'module_name':name}">Activate</button>
<button type="object" class="btn btn-primary btn-sm" name="button_immediate_install_app" invisible="state == 'uninstalled' or module_type == 'official'" groups="base.group_system" context="{'module_name':name}">Upgrade</button>
</xpath>
<xpath expr="//button[@name='button_immediate_install']" position="attributes">
<attribute name="invisible">state != 'uninstalled' or module_type != 'official'</attribute>
</xpath>
<xpath expr="//t[@t-name='kanban-menu']/a[@type='edit']" position="attributes">
<attribute name="context">{'module_type': module_type, 'module_name':name}</attribute>
<attribute name="type">object</attribute>
<attribute name="name">more_info</attribute>
</xpath>
<xpath expr="//div[hasclass('oe_module_action')]/a[@type='edit']" position="attributes">
<attribute name="context">{'module_type': module_type, 'module_name':name}</attribute>
<attribute name="type">object</attribute>
<attribute name="name">more_info</attribute>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="module_tree_apps_inherit">
<field name="name">Apps List Data Modules</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="module_type" invisible="1"/>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="module_form_apps_inherit">
<field name="name">Apps</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.module_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='has_iap']" position="after">
<field name="module_type" invisible="1"/>
</xpath>
<xpath expr="//page[@name='technical_data']" position="attributes">
<attribute name="invisible">module_type != 'official'</attribute>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="view_module_filter_apps_inherit">
<field name="name">Search Data Modules</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.view_module_filter"/>
<field name="arch" type="xml">
<xpath expr="//searchpanel/field[@name='category_id']" position="before">
<field name="module_type" string="Apps" expand="1"/>
</xpath>
</field>
</record>
</data>
</odoo>