diff options
| author | Lucas Lefèvre <lul@odoo.com> | 2019-09-18 12:06:42 +0200 |
|---|---|---|
| committer | Yannick Tivisse <yti@odoo.com> | 2019-09-20 16:18:35 +0200 |
| commit | 5c4544fb2943e63fc1e0f5ae1e7e250a5a2b2cf1 (patch) | |
| tree | b7a9df3d595a8a28632535dc343f8cab1b1bf3cb | |
| parent | 2849b5cb395175c286ddebc269c7b16e7f97e9e6 (diff) | |
[IMP] web: Export grouped list view in xls file
PURPOSE
=======
Add the support of grouped exports.
Allow the user to export all records in one click from
the listview, without having to go through the export modal,
taking into account the domain, groupbys, and visible fields.
SPECIFICATION
=============
When exporting (whether it is from the modal or from the shortcut),
any groupby(s) set on the listview should be taken into account (all unfolded)
- UNLESS the export is import-compatible
- each subgroup header has an indentation compared to its parent
- the 'group headers' in the exported file should contain the
same info (label, field aggregates) as it has in the listview.
New secondary button on the tree view with label 'EXPORT' (to be confirmed...)
- the export shortcut disregard the selected records, it exports all records
according to the domain.
- the button is visible even if there is no selected records.
- essentially the export shortcut does the same thing as the following:
- select all records
- hit 'action' then 'export'
- hit 'export'
Define a boolean attribute on <tree> to specify whether or not the export shortcut should be displayed
Task 2072910
| -rw-r--r-- | addons/web/controllers/main.py | 252 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_controller.js | 35 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_view.js | 1 | ||||
| -rw-r--r-- | addons/web/static/src/js/widgets/data_export.js | 56 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 3 | ||||
| -rw-r--r-- | addons/web/static/tests/widgets/data_export_tests.js | 116 | ||||
| -rw-r--r-- | addons/web/tests/__init__.py | 1 | ||||
| -rw-r--r-- | addons/web/tests/test_export.py | 176 | ||||
| -rw-r--r-- | doc/reference/views.rst | 2 | ||||
| -rw-r--r-- | odoo/addons/base/rng/tree_view.rng | 1 |
10 files changed, 557 insertions, 86 deletions
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 7803f658f39..0e9f4406191 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -26,7 +26,7 @@ import werkzeug.exceptions import werkzeug.utils import werkzeug.wrappers import werkzeug.wsgi -from collections import OrderedDict, defaultdict +from collections import OrderedDict, defaultdict, Counter from werkzeug.urls import url_decode, iri_to_uri from lxml import etree import unicodedata @@ -607,6 +607,162 @@ class HomeStaticTemplateHelpers(object): return cls(addons, db, debug=debug)._get_qweb_templates()[0] +class GroupsTreeNode: + """ + This class builds an ordered tree of groups from the result of a `read_group(lazy=False)`. + The `read_group` returns a list of dictionnaries and each dictionnary is used to + build a leaf. The entire tree is built by inserting all leaves. + """ + + def __init__(self, model, fields, groupby, root=None): + self._model = model + self._fields = fields + self._groupby = groupby + + self.count = 0 # Total number of records in the subtree + self.aggregated_values = Counter() # Fields aggregated values {field_name: aggregated value} + self.children = OrderedDict() + self.data = [] # Only leaf nodes have data + if root: + self.insert_leaf(root) + + def child(self, key): + """ + Return the child identified by `key`. + If it doesn't exists inserts a default node and returns it. + :param key: child key identifier (groupby value as returned by read_group, + usually (id, display_name)) + :return: the child node + """ + if key not in self.children: + self.children[key] = GroupsTreeNode(self._model, self._fields, self._groupby) + return self.children[key] + + def insert_leaf(self, group): + """ + Build a leaf from `group` and insert it in the tree. + :param group: dict as returned by `read_group(lazy=False)` + """ + # Pop every known key in the group dict (__domain, __count and grouped values) + # Remaining keys are aggregated fields. + leaf_path = [group.pop(groupby_field) for groupby_field in self._groupby] + domain = group.pop('__domain') + count = group.pop('__count') + aggregated_values = group + + records = self._model.search(domain, offset=0, limit=False, order=False) + + # Follow the path from the top level group to the deepest + # group which actually contains the records' data. + node = self # root + node.count += count + for node_key in leaf_path: + # Go down to the next node or create one if it does not exist yet. + node = node.child(node_key) + # Update count value and aggregated value. + node.count += count + node.aggregated_values += Counter(aggregated_values) + + node.data = records.export_data(self._fields).get('datas',[]) + + +class ExportXlsxWriter: + + def __init__(self, field_names, row_count=0): + self.field_names = field_names + self.output = io.BytesIO() + self.workbook = xlsxwriter.Workbook(self.output, {'in_memory': True}) + self.base_style = self.workbook.add_format({'text_wrap': True}) + self.header_style = self.workbook.add_format({'bold': True}) + self.header_bold_style = self.workbook.add_format({'text_wrap': True, 'bold': True, 'bg_color': '#e9ecef'}) + self.date_style = self.workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd'}) + self.datetime_style = self.workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd hh:mm:ss'}) + self.worksheet = self.workbook.add_worksheet() + self.value = False + + if row_count > self.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.') % (row_count, self.worksheet.xls_rowmax)) + + def __enter__(self): + self.write_header() + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + self.close() + + def write_header(self): + # Write main header + for i, fieldname in enumerate(self.field_names): + self.write(0, i, fieldname, self.header_style) + self.worksheet.set_column(0, i, 30) # around 220 pixels + + def close(self): + self.workbook.close() + with self.output: + self.value = self.output.getvalue() + + def write(self, row, column, cell_value, style=None): + self.worksheet.write(row, column, cell_value, style) + + def write_cell(self, row, column, cell_value): + cell_style = self.base_style + + 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.") % self.field_names[column]) + + if isinstance(cell_value, str): + if len(cell_value) > self.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.") % self.worksheet.xls_strmax + else: + cell_value = cell_value.replace("\r", " ") + elif isinstance(cell_value, datetime.datetime): + cell_style = self.datetime_style + elif isinstance(cell_value, datetime.date): + cell_style = self.date_style + self.write(row, column, cell_value, cell_style) + +class GroupExportXlsxWriter(ExportXlsxWriter): + + def __init__(self, fields, row_count=0): + super().__init__([f['label'].strip() for f in fields], row_count) + self.fields = fields + + def write_group(self, row, column, group_name, group, group_depth=0): + group_name = group_name if isinstance(group_name, str) else group_name and group_name[1] or _("Undefined") + row, column = self._write_group_header(row, column, group_name, group, group_depth) + + # Recursively write sub-groups + for child_group_name, child_group in group.children.items(): + row, column = self.write_group(row, column, child_group_name, child_group, group_depth + 1) + + for record in group.data: + row, column = self._write_row(row, column, record) + return row, column + + def _write_row(self, row, column, data): + for value in data: + self.write_cell(row, column, value) + column += 1 + return row + 1, 0 + + def _write_group_header(self, row, column, label, group, group_depth=0): + aggregates = group.aggregated_values + + label = '%s%s (%s)' % (' ' * group_depth, label, group.count) + self.write(row, column, label, self.header_bold_style) + for field in self.fields[1:]: # No aggregates allowed in the first column because of the group title + column += 1 + self.write(row, column, aggregates.get(field['name'], ''), self.header_bold_style) + return row + 1, 0 + + #---------------------------------------------------------- # Odoo Web web Controllers #---------------------------------------------------------- @@ -1602,26 +1758,43 @@ class ExportFormat(object): """ raise NotImplementedError() + def from_group_data(self, fields, groups): + raise NotImplementedError() + def base(self, data, token): params = json.loads(data) model, fields, ids, domain, import_compat = \ operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params) - Model = request.env[model].with_context(import_compat=import_compat, **params.get('context', {})) - records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False) - - if not Model._is_an_ordinary_table(): - fields = [field for field in fields if field['name'] != 'id'] - field_names = [f['name'] for f in fields] - import_data = records.export_data(field_names).get('datas',[]) - if import_compat: columns_headers = field_names else: columns_headers = [val['label'].strip() for val in fields] - return request.make_response(self.from_data(columns_headers, import_data), + Model = request.env[model].with_context(**params.get('context', {})) + groupby = params.get('groupby') + if not import_compat and groupby: + domain = [('id', 'in', ids)] if ids else domain + groups_data = Model.read_group(domain, field_names, groupby, lazy=False) + + # read_group(lazy=False) returns a dict only for final groups (with actual data), + # not for intermediary groups. The full group tree must be re-constructed. + tree = GroupsTreeNode(Model, field_names, groupby) + for leaf in groups_data: + tree.insert_leaf(leaf) + + response_data = self.from_group_data(fields, tree) + else: + Model = Model.with_context(import_compat=import_compat) + records = Model.browse(ids) if ids else Model.search(domain, offset=0, limit=False, order=False) + + if not Model._is_an_ordinary_table(): + fields = [field for field in fields if field['name'] != 'id'] + + export_data = records.export_data(field_names).get('datas',[]) + response_data = self.from_data(columns_headers, export_data) + return request.make_response(response_data, headers=[('Content-Disposition', content_disposition(self.filename(model))), ('Content-Type', self.content_type)], @@ -1641,6 +1814,9 @@ class CSVExport(ExportFormat, http.Controller): def filename(self, base): return base + '.csv' + def from_group_data(self, fields, groups): + raise UserError(_("Exporting grouped data to csv is not supported.")) + def from_data(self, fields, rows): fp = io.BytesIO() writer = pycompat.csv_writer(fp, quoting=1) @@ -1673,49 +1849,21 @@ class ExcelExport(ExportFormat, http.Controller): def filename(self, base): return base + '.xlsx' + def from_group_data(self, fields, groups): + with GroupExportXlsxWriter(fields, groups.count) as xlsx_writer: + x, y = 1, 0 + for group_name, group in groups.children.items(): + x, y = xlsx_writer.write_group(x, y, group_name, group) + + return xlsx_writer.value + def from_data(self, fields, rows): - 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.set_column(0, len(fields)-1, 30) # around 220 pixels - - 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): - 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): - 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) - - workbook.close() - with output: - return output.getvalue() + with ExportXlsxWriter(fields, len(rows)) as xlsx_writer: + for row_index, row in enumerate(rows): + for cell_index, cell_value in enumerate(row): + xlsx_writer.write_cell(row_index + 1, cell_index, cell_value) + + return xlsx_writer.value class Apps(http.Controller): @http.route('/apps/<app>', auth='user') diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index dd2adea5a84..b90d903739b 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -22,6 +22,9 @@ var ListController = BasicController.extend({ * the list view. It can be overridden to add buttons in specific child views. */ buttons_template: 'ListView.buttons', + events: _.extend({}, BasicController.prototype.events, { + 'click .o_list_export_xlsx': '_onDirectExportData', + }), custom_events: _.extend({}, BasicController.prototype.custom_events, { activate_next_widget: '_onActivateNextWidget', add_record: '_onAddRecord', @@ -69,14 +72,11 @@ var ListController = BasicController.extend({ * @returns {Promise<array[]>} a promise that resolve to the active domain */ getActiveDomain: function () { - // TODO: this method should be synchronous... var self = this; if (this.$('thead .o_list_record_selector input').prop('checked')) { var searchQuery = this._controlPanel ? this._controlPanel.getSearchQuery() : {}; var record = self.model.get(self.handle, {raw: true}); - return Promise.all(record.getDomain().concat(searchQuery.domain || [])); - } else { - return Promise.resolve(); + return record.getDomain().concat(searchQuery.domain || []); } }, /* @@ -341,6 +341,17 @@ var ListController = BasicController.extend({ }); }, /** + * @returns {DataExport} the export dialog widget + * @private + */ + _getExportDialogWidget() { + let state = this.model.get(this.handle); + let defaultExportFields = this.renderer.columns.map(field => field.attrs.name); + let groupedBy = this.renderer.state.groupedBy; + return new DataExport(this, state, defaultExportFields, groupedBy, + this.getActiveDomain(), this.getSelectedIds()); + }, + /** * @override * @private */ @@ -499,6 +510,8 @@ var ListController = BasicController.extend({ * @returns {Promise} */ _update: function () { + let visilibityFunction = this.renderer.state.count ? 'show' : 'hide' + this.$('.o_list_export_xlsx')[visilibityFunction]() return this._super.apply(this, arguments) .then(this._toggleSidebar.bind(this)) .then(this._toggleCreateButton.bind(this)) @@ -640,11 +653,15 @@ var ListController = BasicController.extend({ * @private */ _onExportData: function () { - var record = this.model.get(this.handle); - var defaultExportFields = _.map(this.renderer.columns, function (field) { - return field.attrs.name; - }); - new DataExport(this, record, defaultExportFields).open(); + this._getExportDialogWidget().open(); + }, + /** + * Export Records in a xls file + * + * @private + */ + _onDirectExportData() { + this._getExportDialogWidget().export(); }, /** * Opens the related form view. diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js index baba4d4a35f..f3f125ba8e3 100644 --- a/addons/web/static/src/js/views/list/list_view.js +++ b/addons/web/static/src/js/views/list/list_view.js @@ -52,6 +52,7 @@ var ListView = BasicView.extend({ } }); + this.controllerParams.activeActions.export_xlsx = this.arch.attrs.export_xlsx ? !!JSON.parse(this.arch.attrs.export_xlsx): true; this.controllerParams.editable = (!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) ? this.arch.attrs.editable : false; this.controllerParams.hasSidebar = params.hasSidebar; diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index 070b1927c5f..97ec75e6b47 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -34,11 +34,11 @@ var DataExport = Dialog.extend({ * @param {Object} record * @param {string[]} defaultExportFields */ - init: function (parent, record, defaultExportFields) { + init: function (parent, record, defaultExportFields, groupedBy, activeDomain, idsToExport) { var options = { title: _t("Export Data"), buttons: [ - {text: _t("Export"), click: this._exportData, classes: 'btn-primary'}, + {text: _t("Export"), click: this._onExportData, classes: 'btn-primary'}, {text: _t("Close"), close: true}, ], }; @@ -46,9 +46,13 @@ var DataExport = Dialog.extend({ this.records = {}; this.record = record; this.defaultExportFields = defaultExportFields; + this.groupby = groupedBy; this.exports = new data.DataSetSearch(this, 'ir.exports', this.record.getContext()); this.rowIndex = 0; this.rowIndexLevel = 0; + this.isCompatibleMode = false; + this.domain = activeDomain || this.record.domain; + this.idsToExport = activeDomain ? false: idsToExport; }, /** * @override @@ -73,16 +77,6 @@ var DataExport = Dialog.extend({ }); })); - proms.push(this.getParent().getActiveDomain().then(function (domain) { - if (domain === undefined) { - self.idsToExport = self.getParent().getSelectedIds(); - self.domain = self.record.domain; - } else { - self.idsToExport = false; - self.domain = domain; - } - })); - proms.push(this._showExportsList()); // Bind sortable events after Dialog is open @@ -117,6 +111,17 @@ var DataExport = Dialog.extend({ } }, + /** + * Export all data with default values (fields, domain) + */ + export() { + let exportedFields = this.defaultExportFields.map(field => ({ + name: field, + label: this.record.fields[field].string, + })); + this._exportData(exportedFields, 'xlsx', false); + }, + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -146,13 +151,7 @@ var DataExport = Dialog.extend({ * * @private */ - _exportData: function () { - var exportedFields = this.$('.o_export_field').map(function () { - return { - name: $(this).data('field_id'), - label: this.textContent - }; - }).get(); + _exportData(exportedFields, exportFormat, idsToExport) { if (_.isEmpty(exportedFields)) { Dialog.alert(this, _t("Please select fields to export...")); @@ -162,8 +161,6 @@ var DataExport = Dialog.extend({ exportedFields.unshift({ name: 'id', label: _t('External ID') }); } - var exportFormat = this.$exportFormatInputs.filter(':checked').val(); - framework.blockUI(); this.getSession().get_file({ url: '/web/export/' + exportFormat, @@ -171,8 +168,9 @@ var DataExport = Dialog.extend({ data: JSON.stringify({ model: this.record.model, fields: exportedFields, - ids: this.idsToExport, + ids: idsToExport, domain: this.domain, + groupby: this.groupby, context: pyUtils.eval('contexts', [this.record.getContext()]), import_compat: this.isCompatibleMode, }) @@ -546,6 +544,20 @@ var DataExport = Dialog.extend({ } }, /** + * Submit the user data and export the file + * + * @private + */ + _onExportData() { + let exportedFields = this.$('.o_export_field').map((i, field) => ({ + name: $(field).data('field_id'), + label: field.textContent, + } + )).get(); + let exportFormat = this.$exportFormatInputs.filter(':checked').val(); + this._exportData(exportedFields, exportFormat, this.idsToExport); + }, + /** * Add a field to export field list on double click * * @private diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 20c76190c7f..54ffb93a059 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -275,6 +275,9 @@ Create </button> </t> + <t t-if="widget.is_action_enabled('export_xlsx')"> + <button type="button" class="btn btn-secondary fa fa-download o_list_export_xlsx" title="Export All"/> + </t> </div> </t> diff --git a/addons/web/static/tests/widgets/data_export_tests.js b/addons/web/static/tests/widgets/data_export_tests.js index 6d29e8e888d..c9c51219571 100644 --- a/addons/web/static/tests/widgets/data_export_tests.js +++ b/addons/web/static/tests/widgets/data_export_tests.js @@ -14,12 +14,22 @@ QUnit.module('widgets', { partner: { fields: { foo: {string: "Foo", type: "char"}, + bar: {string: "Bar", type: "char"}, }, records: [ { id: 1, foo: "yop", - }, + bar: "bar-blup", + }, { + id: 2, + foo: "yop", + bar: "bar-yop", + }, { + id: 3, + foo: "blup", + bar: "bar-blup", + } ] }, 'ir.exports': { @@ -239,7 +249,7 @@ QUnit.module('widgets', { }, }); // Open the export modal - await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')); + await testUtils.dom.click(list.$('thead .o_list_record_selector input')); await testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)')); await testUtils.dom.click(list.sidebar.$('a:contains(Export)')); @@ -257,6 +267,108 @@ QUnit.module('widgets', { assert.strictEqual($('.modal .o_fields_list li').length, 1, "There should be only one field in list"); list.destroy(); }); + + QUnit.test('Direct export button invisible', async function (assert) { + assert.expect(1) + + let list = await createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: `<tree export_xlsx="0"><field name="foo"/></tree>`, + }); + assert.containsNone(list, '.o_list_export_xlsx') + list.destroy(); + }); + + QUnit.test('Direct export list ', async function (assert) { + assert.expect(2) + + let list = await createView({ + View: ListView, + model: 'partner', + data: this.data, + arch: ` + <tree export_xlsx="1"> + <field name="foo"/> + <field name="bar"/> + </tree>`, + domain: [['bar', '!=', 'glou']], + session: { + get_file(args) { + let data = JSON.parse(args.data.data); + assert.strictEqual(args.url, '/web/export/xlsx', "should call get_file with the correct url"); + assert.deepEqual(data, { + context: {}, + model: 'partner', + domain: [['bar', '!=', 'glou']], + groupby: [], + ids: false, + import_compat: false, + fields: [{ + name: 'foo', + label: 'Foo', + }, { + name: 'bar', + label: 'Bar', + }] + }, "should be called with correct params") + args.complete(); + }, + }, + }); + + // select a record => should not be taken into account + await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')[0]); + // Download + await testUtils.dom.click(list.$buttons.find('.o_list_export_xlsx')); + + list.destroy(); + }); + + QUnit.test('Direct export grouped list ', async function (assert) { + assert.expect(2) + + let list = await createView({ + View: ListView, + model: 'partner', + data: this.data, + debug: true, + arch: ` + <tree> + <field name="foo"/> + <field name="bar"/> + </tree>`, + groupBy: ['foo', 'bar'], + domain: [['bar', '!=', 'glou']], + session: { + get_file(args) { + let data = JSON.parse(args.data.data); + assert.strictEqual(args.url, '/web/export/xlsx', "should call get_file with the correct url"); + assert.deepEqual(data, { + context: {}, + model: 'partner', + domain: [['bar', '!=', 'glou']], + groupby: ['foo', 'bar'], + ids: false, + import_compat: false, + fields: [{ + name: 'foo', + label: 'Foo', + }, { + name: 'bar', + label: 'Bar', + }] + }, "should be called with correct params") + args.complete(); + }, + }, + }); + + await testUtils.dom.click(list.$buttons.find('.o_list_export_xlsx')); + + list.destroy(); + }); }); }); diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index 1ed329bafe1..b479a574e46 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_js from . import test_menu from . import test_serving_base from . import test_click_everywhere +from . import test_export diff --git a/addons/web/tests/test_export.py b/addons/web/tests/test_export.py new file mode 100644 index 00000000000..b0fed393e10 --- /dev/null +++ b/addons/web/tests/test_export.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch +import json +from odoo import http +from odoo.tests import common, new_test_user +from odoo.addons.web.controllers.main import ExportXlsxWriter + + +class TestExport(common.HttpCase): + + def setUp(self): + super().setUp() + + # Archive demo partners + self.env['res.users'].search([]).active = False + self.env['res.partner'].search([]).active = False + new_test_user(self.env, login='fef', password='pwd') + self.authenticate('fef', 'pwd') + + self.env['res.partner'].create([{ + 'name': 'partner 1', + 'comment': 'yop', + 'website': 'yop.com', + 'credit_limit': 0, + },{ + 'name': 'partner 2', + 'comment': 'yop', + 'website': 'blop.com', + 'credit_limit': 3, + },{ + 'name': 'partner 3', + 'comment': 'yop', + 'website': 'blop.com', + 'credit_limit': 1, + }, { + 'name': 'partner 4', + 'comment': 'blop', + 'website': 'blop.com', + 'credit_limit': 2, + }]) + + self.worksheet = {} # mock worksheet + + self.default_params = { + 'domain': [], + 'fields': [{ + 'label': "Partner name", + 'name': 'name', + }, { + 'label': "Comment", + 'name': "comment", + }, { + 'label': 'Website', + 'name': 'website', + }], + 'groupby': [], + 'ids': False, + 'import_compat': False, + 'model': 'res.partner', + } + + def mock_write(self, row, column, value, style=None): + self.worksheet[row, column] = value + + def test_grouped_list_export(self): + + with patch.object(ExportXlsxWriter, 'write', self.mock_write): + self.default_params['groupby'] = ['comment', 'website'] + params = dict(self.default_params, **{ + 'groupby': ['comment', 'website'], + 'fields': [*self.default_params['fields'], {'label': "Credit Limit", 'name': 'credit_limit'}] + }) + self.url_open('/web/export/xlsx', data={ + 'data': json.dumps(params), + 'token': 'dummy', + 'csrf_token': http.WebRequest.csrf_token(self), + }) + + # Main header + self.assertEqual(self.worksheet[0, 0], "Partner name", "It should display the list header") + self.assertEqual(self.worksheet[0, 1], "Comment", "It should display the list header") + self.assertEqual(self.worksheet[0, 2], "Website", "It should display the list header") + self.assertEqual(self.worksheet[0, 3], "Credit Limit", "It should display the list header") + + # Header: Group Comment=blop + self.assertEqual(self.worksheet[1, 0], "blop (1)", "It should display the group header") + self.assertEqual(self.worksheet[1, 3], 2, "It should display the aggregated value") + + # Header: Group Comment=blop > Website=http://blop.com + self.assertEqual(self.worksheet[2, 0], " http://blop.com (1)", "It should display the group header") + self.assertEqual(self.worksheet[2, 3], 2, "It should display the aggregated value") + + # Data: Group Comment=blop > Website=http://blop.com + self.assertEqual(self.worksheet[3, 0], "partner 4") + self.assertEqual(self.worksheet[3, 1], "blop") + self.assertEqual(self.worksheet[3, 2], "http://blop.com") + self.assertEqual(self.worksheet[3, 3], 2) + + # Header: Group Comment=yop + self.assertEqual(self.worksheet[4, 0], "yop (3)", "It should display the group header") + self.assertEqual(self.worksheet[4, 3], 4, "It should display the aggregated value") + + # Header: Group Comment=yop > Website=http://blop.com + self.assertEqual(self.worksheet[5, 0], " http://blop.com (2)", "It should display the group header") + self.assertEqual(self.worksheet[5, 3], 4, "It should display the aggregated value") + + # Data: Group Comment=yop > Website=http://blop.com + self.assertEqual(self.worksheet[6, 0], "partner 2") + self.assertEqual(self.worksheet[6, 1], "yop") + self.assertEqual(self.worksheet[6, 2], "http://blop.com") + self.assertEqual(self.worksheet[6, 3], 3) + + self.assertEqual(self.worksheet[7, 0], "partner 3") + self.assertEqual(self.worksheet[7, 1], "yop") + self.assertEqual(self.worksheet[7, 2], "http://blop.com") + self.assertEqual(self.worksheet[7, 3], 1) + + # Header: Group Comment=yop > Website=http://yop.com + self.assertEqual(self.worksheet[8, 0], " http://yop.com (1)", "It should display the group header") + self.assertEqual(self.worksheet[8, 3], '', "It should not have any aggregated value") + + # Data: Group Comment=yop > Website=http://yop.com + self.assertEqual(self.worksheet[9, 0], "partner 1") + self.assertEqual(self.worksheet[9, 1], "yop") + self.assertEqual(self.worksheet[9, 2], "http://yop.com") + self.assertEqual(self.worksheet[9, 3], 0) + + # Header: Group Comment=False + self.assertEqual(self.worksheet[10, 0], "Undefined (1)", "It should display the group header") + self.assertEqual(self.worksheet[10, 3], '', "It should not have any aggregated value") + + # Header: Group Comment=False > Website=False + self.assertEqual(self.worksheet[11, 0], " Undefined (1)", "It should display the group header") + self.assertEqual(self.worksheet[11, 3], '', "It should not have any aggregated value") + + # Data: Group Comment=False > Website=False + self.assertEqual(self.worksheet[12, 0], "fef (base.group_user)") + self.assertEqual(self.worksheet[12, 1], "") + self.assertEqual(self.worksheet[12, 2], "") + self.assertEqual(self.worksheet[12, 3], 0) + + def test_list_export(self): + + with patch.object(ExportXlsxWriter, 'write', self.mock_write): + self.url_open('/web/export/xlsx', data={ + 'data': json.dumps(self.default_params), + 'token': 'dummy', + 'csrf_token': http.WebRequest.csrf_token(self), + }) + + # Main header + self.assertEqual(self.worksheet[0, 0], "Partner name", "It should display the list header") + self.assertEqual(self.worksheet[0, 1], "Comment", "It should display the list header") + self.assertEqual(self.worksheet[0, 2], "Website", "It should display the list header") + + self.assertEqual(self.worksheet[1, 0], "fef (base.group_user)") + self.assertEqual(self.worksheet[1, 1], "") + self.assertEqual(self.worksheet[1, 2], "") + + self.assertEqual(self.worksheet[2, 0], "partner 1") + self.assertEqual(self.worksheet[2, 1], "yop") + self.assertEqual(self.worksheet[2, 2], "http://yop.com") + + self.assertEqual(self.worksheet[3, 0], "partner 2") + self.assertEqual(self.worksheet[3, 1], "yop") + self.assertEqual(self.worksheet[3, 2], "http://blop.com") + + self.assertEqual(self.worksheet[4, 0], "partner 3") + self.assertEqual(self.worksheet[4, 1], "yop") + self.assertEqual(self.worksheet[4, 2], "http://blop.com") + + self.assertEqual(self.worksheet[5, 0], "partner 4") + self.assertEqual(self.worksheet[5, 1], "blop") + self.assertEqual(self.worksheet[5, 2], "http://blop.com") diff --git a/doc/reference/views.rst b/doc/reference/views.rst index efe07c48076..139e040f0f6 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -219,7 +219,7 @@ root can have the following attributes: (``font-style: italic``), or any `bootstrap contextual color <https://getbootstrap.com/docs/3.3/components/#available-variations>`_ (``danger``, ``info``, ``muted``, ``primary``, ``success`` or ``warning``). -``create``, ``edit``, ``delete``, ``duplicate``, ``import`` +``create``, ``edit``, ``delete``, ``duplicate``, ``import``, ``export_xlsx`` allows *dis*\ abling the corresponding action in the view by setting the corresponding attribute to ``false`` ``limit`` diff --git a/odoo/addons/base/rng/tree_view.rng b/odoo/addons/base/rng/tree_view.rng index 6ba2bf53899..e7373dc532a 100644 --- a/odoo/addons/base/rng/tree_view.rng +++ b/odoo/addons/base/rng/tree_view.rng @@ -27,6 +27,7 @@ <rng:optional><rng:attribute name="create"/></rng:optional> <rng:optional><rng:attribute name="delete"/></rng:optional> <rng:optional><rng:attribute name="edit"/></rng:optional> + <rng:optional><rng:attribute name="export_xlsx"/></rng:optional> <rng:optional><rng:attribute name="duplicate"/></rng:optional> <rng:optional><rng:attribute name="import"/></rng:optional> <rng:optional><rng:attribute name="string"/></rng:optional> <!-- deprecated, has no effect anymore --> |
