diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 11996d46ecc..1487fbdeba9 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -39,7 +39,7 @@ from odoo.modules import get_module_path, get_resource_path from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs from odoo.tools.mimetypes import guess_mimetype from odoo.tools.translate import _ -from odoo.tools.misc import str2bool, xlwt, file_open +from odoo.tools.misc import str2bool, xlsxwriter, file_open from odoo.tools.safe_eval import safe_eval from odoo import http, tools from odoo.http import content_disposition, dispatch_rpc, request, Response @@ -1441,7 +1441,7 @@ class Export(http.Controller): :rtype: [(str, str)] """ return [ - {'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT 1.3.0 required"}, + {'tag': 'xlsx', 'label': 'XLSX', 'error': None if xlsxwriter else "XlsxWriter 0.9.3 required"}, {'tag': 'csv', 'label': 'CSV'}, ] @@ -1455,23 +1455,21 @@ class Export(http.Controller): import_compat=True, parent_field_type=None, parent_field=None, exclude=None): - if import_compat and parent_field_type in ['many2one', 'many2many']: - fields = self.fields_get(model) - fields = {k: v for k, v in fields.items() if k in ['id', 'name']} + fields = self.fields_get(model) + if import_compat: + if parent_field_type in ['many2one', 'many2many']: + fields = {'id': fields['id'], 'name': fields['name']} else: - fields = self.fields_get(model) + fields['.id'] = {**fields['id']} - if not import_compat: - fields['.id'] = fields.pop('id', {'string': 'ID'}) - else: - fields['id']['string'] = _('External ID') + fields['id']['string'] = _('External ID') if parent_field: parent_field['string'] = _('External ID') fields['id'] = parent_field fields_sequence = sorted(fields.items(), - key=lambda field: (field[0] not in ['id', '.id', 'display_name', 'name'], odoo.tools.ustr(field[1].get('string', '')))) + key=lambda field: odoo.tools.ustr(field[1].get('string', '').lower())) records = [] for field_name, field in fields_sequence: @@ -1487,12 +1485,13 @@ class Export(http.Controller): continue id = prefix + (prefix and '/'or '') + field_name + val = id if field_name == 'name' and import_compat and parent_field_type in ['many2one', 'many2many']: # Add name field when expand m2o and m2m fields in import-compatible mode - id = prefix + val = prefix name = parent_name + (parent_name and '/' or '') + field['string'] record = {'id': id, 'string': name, - 'value': id, 'children': False, + 'value': val, 'children': False, 'field_type': field.get('type'), 'required': field.get('required'), 'relation_field': field.get('relation_field')} @@ -1658,63 +1657,61 @@ class CSVExport(ExportFormat, http.Controller): class ExcelExport(ExportFormat, http.Controller): - @http.route('/web/export/xls', type='http', auth="user") + @http.route('/web/export/xlsx', type='http', auth="user") @serialize_exception def index(self, data, token): return self.base(data, token) @property def content_type(self): - return 'application/vnd.ms-excel' + return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' def filename(self, base): - return base + '.xls' + return base + '.xlsx' def from_data(self, fields, rows): - if len(rows) > 65535: - raise UserError(_('There are too many rows (%s rows, limit: 65535) to export as Excel 97-2003 (.xls) format. Consider splitting the export.') % len(rows)) - - workbook = xlwt.Workbook() - worksheet = workbook.add_sheet('Sheet 1') + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {'in_memory': True}) + worksheet = workbook.add_worksheet() + if len(rows) > worksheet.xls_rowmax: + raise UserError(_('There are too many rows (%s rows, limit: %s) to export as Excel 2007-2013 (.xlsx) format. Consider splitting the export.') % (len(rows), worksheet.xls_rowmax)) for i, fieldname in enumerate(fields): worksheet.write(0, i, fieldname) - worksheet.col(i).width = 8000 # around 220 pixels + worksheet.set_column(0, len(fields)-1, 30) # around 220 pixels - base_style = xlwt.easyxf('align: wrap yes') - date_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD') - datetime_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD HH:mm:SS') + base_style = workbook.add_format({'text_wrap': True}) + date_style = workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd'}) + datetime_style = workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd hh:mm:ss'}) for row_index, row in enumerate(rows): for cell_index, cell_value in enumerate(row): cell_style = base_style - if isinstance(cell_value, bytes) and not isinstance(cell_value, str): - # because xls uses raw export, we can get a bytes object - # here. xlwt does not support bytes values in Python 3 -> - # assume this is base64 and decode to a string, if this - # fails note that you can't export + if isinstance(cell_value, bytes): try: + # because xlsx uses raw export, we can get a bytes object + # here. xlsxwriter does not support bytes values in Python 3 -> + # assume this is base64 and decode to a string, if this + # fails note that you can't export cell_value = pycompat.to_text(cell_value) except UnicodeDecodeError: raise UserError(_("Binary fields can not be exported to Excel unless their content is base64-encoded. That does not seem to be the case for %s.") % fields[cell_index]) if isinstance(cell_value, str): - cell_value = re.sub("\r", " ", pycompat.to_text(cell_value)) - # Excel supports a maximum of 32767 characters in each cell: - cell_value = cell_value[:32767] + if len(cell_value) > worksheet.xls_strmax: + cell_value = _("The content of this cell is too long for an XLSX file (more than %s characters). Please use the CSV format for this export.") % worksheet.xls_strmax + else: + cell_value = cell_value.replace("\r", " ") elif isinstance(cell_value, datetime.datetime): cell_style = datetime_style elif isinstance(cell_value, datetime.date): cell_style = date_style worksheet.write(row_index + 1, cell_index, cell_value, cell_style) - fp = io.BytesIO() - workbook.save(fp) - fp.seek(0) - data = fp.read() - fp.close() - return data + workbook.close() + with output: + return output.getvalue() class Apps(http.Controller): @http.route('/apps/', auth='user') diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index 9031dd43232..975cc99e35c 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -19,15 +19,14 @@ var DataExport = Dialog.extend({ 'click .o_add_field': '_onClickAddField', 'click .o_delete_exported_list': '_onClickDeleteExportListBtn', 'click .o_expand': '_onClickExpand', - 'click .o_move_down': '_onClickMovedown', - 'click .o_move_up': '_onClickMoveup', - 'click .o_remove_all_field': '_onClickRemoveAllFields', 'click .o_remove_field': '_onClickRemoveField', - 'click .o_toggle_save_list': '_onClickSaveList', - 'click .o_save_list > button': '_onClickSaveListBtn', + 'click .o_save_list .o_save_list_btn': '_onClickSaveListBtn', + 'click .o_save_list .o_cancel_list_btn': '_resetTemplateField', 'click .o_export_tree_item': '_onClickTreeItem', 'dblclick .o_export_tree_item:not(.haschild)': '_onDblclickTreeItem', - 'keydown .o_export_tree_item': '_onKeydownTreeItem' + 'keydown .o_export_tree_item': '_onKeydownTreeItem', + 'keydown .o_save_list_name': '_onKeydownSaveList', + 'input .o_export_search_input': '_onSearchInput', }, /** * @constructor @@ -39,7 +38,7 @@ var DataExport = Dialog.extend({ var options = { title: _t("Export Data"), buttons: [ - {text: _t("Export To File"), click: this._exportData, classes: 'btn-primary'}, + {text: _t("Export"), click: this._exportData, classes: 'btn-primary'}, {text: _t("Close"), close: true}, ], }; @@ -65,7 +64,6 @@ var DataExport = Dialog.extend({ this.$modal.find('.modal-content').css('height', '100%'); this.$fieldsList = this.$('.o_fields_list'); - this.$importCompatRadios = this.$('.o_import_compat input'); proms.push(this._rpc({route: '/web/export/formats'}).then(doSetupExportFormats)); proms.push(this._onChangeCompatibleInput()); @@ -81,29 +79,37 @@ var DataExport = Dialog.extend({ })); proms.push(this._showExportsList().then(function () { - _.each(self.records, function (record, key) { - if (_.contains(self.defaultExportFields, key)) { - self._addField(key, record.string); - } + _.each(self.defaultExportFields, function (field) { + var record = self.records[field]; + self._addField(record.id, record.string); }); })); + // Bind sortable events after Dialog is open + this.opened().then(function () { + self.$('.o_fields_list').sortable({ + axis: 'y', + handle: '.o_short_field', + forcePlaceholderSize: true, + placeholder: 'o-field-placeholder', + update: self.proxy('_resetTemplateField'), + }); + }); return Promise.all(proms); function doSetupExportFormats(formats) { var $fmts = self.$('.o_export_format'); _.each(formats, function (format) { - var $radio = $('', {type: 'radio', value: format.tag, name: 'o_export_format_name'}); - var $label = $('', {html: format.label}); + var $radio = $('', {type: 'radio', value: format.tag, name: 'o_export_format_name', class: 'form-check-input', id: 'o_radio' + format.label}); + var $label = $('