diff options
| -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 --> |
