[MERGE] web: Re-implement the XLS grouped export

Purpose
=======

The commit 2849b5c introduces a new export mechanism of grouped
list views to xls files.

The issue with this development is mainly that the displayed records
are exported, instead of all the records that match the search
parameters.

To export all the records we cannot rely on the data from the web
client. This implies to revert 2849b5c, and implement it in a better
way.

Functional Spec
===============

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.

Technical Spec
==============

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

closes odoo/odoo#37087

Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Odoo's Mergebot
2019-09-20 17:13:47 +02:00
committed by GitHub
14 changed files with 544 additions and 532 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main, pivot, list_controller
from . import main, pivot
-98
View File
@@ -1,98 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from datetime import datetime
from odoo import http
from odoo.http import request
from odoo.tools.misc import xlwt
HEADER_BOLD = xlwt.easyxf("font: bold on; pattern: pattern solid, fore_colour gray25;")
class TableListExporter(http.Controller):
def _write_group_header(self, worksheet, x, y, group, columns, group_depth=0):
aggregates = group.get('aggregateValues', {})
title = group.get('value')
if group.get('hideHeader'):
return x, y
title = '%s%s (%s)' % ('>' * group_depth, title, group.get('count', 0))
worksheet.write(x, y, title, HEADER_BOLD)
for column in columns[1:]: # No aggregates allowed in the first column
y += 1
worksheet.write(x, y, aggregates.get(column['field'], ''), HEADER_BOLD)
return x + 1, 0
def _write_record(self, worksheet, x, y, record, columns):
for column in columns:
worksheet.write(x, y, record[column['field']])
y += 1
return x + 1, 0
def _write_groups(self, worksheet, x, y, groups, columns, group_depth=0):
"""
Write groups to the xls worksheet. Recursive if groups are nested (chained groupby)
:param worksheet:
:param x: start position where to write the group (vertical axis, pointing downward)
:param y: start position where to write the group (horizontal axis, pointing to the right)
:param groups: list of groups. Each group is a dict containing either records' data
or sub-groups. A group has the following structure:
'data': list of records or list of sub-groups
'isGrouped': True if data contains sub-groups
'value': title of the group
'count': number of records in the group
'aggregateValues': dict container aggregated values of fields {field: aggregatedValue, ...}
'hideHeader': self explanatory, used when the records are not grouped at all.
:param columns: list of displayed columns. Each column is a dict with the following keys:
'field': field name
'aggregateValue': aggregate for all records
'string':
"""
for group in groups:
x, y = self._write_group_header(worksheet, x, y, group, columns, group_depth)
if group['isGrouped']:
# Recursively write sub-groups
x, y = self._write_groups(worksheet, x, y, group['data'], columns, group_depth + 1)
else:
for record in group['data']:
x, y = self._write_record(worksheet, x, y, record, columns)
return x, y
def _write_worksheet(self, worksheet, data):
bold = xlwt.easyxf("font: bold on;")
columns = data['columns']
groups = data['groups']
# Write main header
for y, field in enumerate(columns):
worksheet.write(0, y, field['string'], bold)
# Write data
x, y = 1, 0
x, y = self._write_groups(worksheet, x, y, groups, columns)
# Write column aggregates
for y, field in enumerate(columns):
worksheet.write(x, y, field.get('aggregateValue', ''), bold)
@http.route('/web/list/export_xls', type='http', auth="user")
def export_xls(self, data, token):
data = json.loads(data)
title = data.get('title', 'export')
workbook = xlwt.Workbook()
worksheet = workbook.add_sheet(title)
self._write_worksheet(worksheet, data)
response = request.make_response(None,
headers=[('Content-Type', 'application/vnd.ms-excel'),
('Content-Disposition', 'attachment; filename="%s - %s.xls"' % (title, datetime.now()))],
cookies={'fileToken': token})
workbook.save(response.stream)
return response
+199 -51
View File
@@ -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))
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)
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()
return xlsx_writer.value
class Apps(http.Controller):
@http.route('/apps/<app>', auth='user')
@@ -8,69 +8,14 @@ odoo.define('web.ListController', function (require) {
*/
var core = require('web.core');
var session = require('web.session');
var framework = require('web.framework');
var BasicController = require('web.BasicController');
var DataExport = require('web.DataExport');
var Dialog = require('web.Dialog');
var fieldUtils = require('web.field_utils');
var Sidebar = require('web.Sidebar');
var _t = core._t;
var qweb = core.qweb;
/**
* Return data required for exporting the current list to a xls file.
* Recursive if some groups are nested.
* Note: this function is also used when the records are not grouped.
* In this scenario, an artificial single group with every record in
* the list is built.
* @param {Array} groups
* @param {Array} displayedFields
* @param {Array} allFields
* @returns {Array} list of groups with the following structure:
* 'data': list of records or list of sub-groups
* 'isGrouped': true if data contains sub-groups
* 'value': title of the group
* 'count': number of records in the group
* 'aggregateValues': dict container aggregated values of fields {field: aggregatedValue, ...}
* 'hideHeader': self explanatory, used when the records are not grouped at all.
*/
var processGroups = (groups, displayedFields, allFields) => {
let formatRecord = record => {
let data = _.pick(record.data, displayedFields)
let formattedData = {}
Object.entries(data).forEach(([field_name, value]) => {
let field = allFields[field_name]
let formattedValue = fieldUtils.format[field.type](value, field, {data: record.data, forceString: true})
formattedData[field_name] = formattedValue.replace('&nbsp;', '')
});
return formattedData
}
if (groups.length && groups[0].groupedBy && groups[0].groupedBy.length) {
// Recursively process sub-group
return groups.map(group => ({
hideHeader: !!group.hideHeader,
isGrouped: true,
value: group.value !== undefined && group.value.toString() || _t("Undefined"),
count: group.count,
aggregateValues: _.pick(group.aggregateValues, displayedFields),
data: processGroups(group.data, displayedFields, allFields),
}))
}
// process records
return groups.map(group => ({
hideHeader: !!group.hideHeader,
isGrouped: false,
value: group.value !== undefined && group.value.toString() || _t("Undefined"),
count: group.count,
aggregateValues: _.pick(group.aggregateValues, displayedFields),
data: group.data.map(formatRecord)
}))
}
var ListController = BasicController.extend({
/**
* This key contains the name of the buttons template to render on top of
@@ -78,7 +23,7 @@ var ListController = BasicController.extend({
*/
buttons_template: 'ListView.buttons',
events: _.extend({}, BasicController.prototype.events, {
'click .o_list_download': '_onExportRecords',
'click .o_list_export_xlsx': '_onDirectExportData',
}),
custom_events: _.extend({}, BasicController.prototype.custom_events, {
activate_next_widget: '_onActivateNextWidget',
@@ -109,9 +54,6 @@ var ListController = BasicController.extend({
this.selectedRecords = params.selectedRecords || [];
this.multipleRecordsSavingPromise = null;
this.fieldChangedPrevented = false;
this.activeActions = _.extend({}, this.activeActions, {
download: this.renderer.arch.attrs.download ? !!JSON.parse(this.renderer.arch.attrs.download) : true,
});
},
//--------------------------------------------------------------------------
@@ -130,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 || []);
}
},
/*
@@ -402,29 +341,15 @@ var ListController = BasicController.extend({
});
},
/**
* Export the current list data in a xls file.
*
* @returns {DataExport} the export dialog widget
* @private
*/
_downloadList() {
let groups = this.renderer.state.data
groups = this.renderer.isGrouped ? groups : [{data: groups, count: groups.length, hideHeader: true}] // Artificial single group
let allFields = this.renderer.state.fields
let columns = this.renderer.columns.map(column => ({
field: column.attrs.name,
aggregateValue: column.aggregate && column.aggregate.value,
string: allFields[column.attrs.name].string,
}))
groups = processGroups(groups, columns.map(c => c.field), allFields)
return session.get_file({
url: '/web/list/export_xls',
data: {data: JSON.stringify({columns, groups, title: this._title})},
complete: framework.unblockUI,
error: (error) => this.call('crash_manager', 'rpc_error', error),
});
_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
@@ -585,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))
@@ -726,19 +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
*/
_onExportRecords() {
this._downloadList()
_onDirectExportData() {
this._getExportDialogWidget().export();
},
/**
* Opens the related form view.
@@ -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;
+34 -22
View File
@@ -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,
})
@@ -545,6 +543,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
*
+2 -2
View File
@@ -275,8 +275,8 @@
Create
</button>
</t>
<t t-if="widget.is_action_enabled('download')">
<button type="button" class="btn btn-secondary fa fa-download o_list_download" title="Download xls" aria-label="Download xls"/>
<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>
-187
View File
@@ -3544,193 +3544,6 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('export list view to xls with nested groups', async function (assert) {
assert.expect(5);
let currencies = {};
_.each(this.data.res_currency.records, function (currency) {
currencies[currency.id] = currency;
});
let list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: `
<tree download="1">
<field name="foo"/>
<field name="bar"/>
<field name="int_field" sum="Total"/>
<field name="datetime"/>
<field name="amount" widget="monetary"/>
<field name="currency_id" invisible="1"/>
<field name="o2m"/>
<field name="m2o"/>
<field name="m2m"/>
</tree>`,
groupBy: ['foo', 'bar'],
session: {
currencies: currencies,
get_file(args) {
let data = JSON.parse(args.data.data);
assert.strictEqual(args.url, '/web/list/export_xls',
"should call get_file with the correct url");
assert.deepEqual(data.columns, [
{field: "foo", string: "Foo"},
{field: "bar", string: "Bar"},
{field: "int_field", aggregateValue: 32, string: "int_field"},
{field: "datetime", string: "Datetime Field"},
{field: "amount", string: "Monetary field"},
{field: "o2m", string: "O2M field"},
{field: "m2o", string: "M2O field"},
{field: "m2m", string: "M2M field"},
], "columns should be in the correct order with the string and aggregate value")
assert.deepEqual(data.groups[0], {
// Group "yop"
isGrouped: true,
count: 1,
aggregateValues: {int_field: 10},
hideHeader: false,
value: "yop",
data: [{
// Group "yop > true"
isGrouped: false,
count: 1,
aggregateValues: {int_field: 10},
hideHeader: false,
value: "true",
data: [{
// Record "yop > true"
foo: "yop",
bar: "True",
int_field: "10",
datetime: "12/12/2016 10:55:05",
amount: "1200.00€",
m2m: "2 records",
m2o: "Value 1",
o2m: "No records",
}]
}]
});
assert.deepEqual(data.groups[1], {
// Group "blip"
isGrouped: true,
count: 2,
aggregateValues: {int_field: 5},
hideHeader: false,
value: "blip",
data: [{
// Group "blip > true"
isGrouped: false,
count: 1,
aggregateValues: {int_field: 9},
hideHeader: false,
value: "true",
data: [],
}, {
// Group "blip > false"
isGrouped: false,
count: 1,
aggregateValues: {int_field: -4},
hideHeader: false,
value: "false",
data: [],
}]
});
assert.deepEqual(data.groups[2], {
// Group "gnap"
isGrouped: true,
count: 1,
aggregateValues: {int_field: 17},
hideHeader: false,
value: "gnap",
data: [],
});
args.complete();
},
},
});
// open the first group
await testUtils.dom.click(list.$('.o_group_header:first'));
// open subgroup
let $openGroup = list.$('tbody:nth(1)');
await testUtils.dom.click($openGroup.find('.o_group_header:first'));
// open the second group
await testUtils.dom.click(list.$('.o_group_header:nth(2)'));
// Download
await testUtils.dom.click(list.$buttons.find('.o_list_download'));
list.destroy();
});
QUnit.test('export list view to xls', async function (assert) {
assert.expect(3);
let currencies = {};
_.each(this.data.res_currency.records, function (currency) {
currencies[currency.id] = currency;
});
let list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: `
<tree>
<field name="foo"/>
<field name="bar"/>
<field name="int_field" sum="Total"/>
<field name="datetime"/>
<field name="amount" widget="monetary"/>
<field name="currency_id" invisible="1"/>
<field name="o2m"/>
<field name="m2o"/>
<field name="m2m"/>
</tree>`,
session: {
currencies: currencies,
get_file(args) {
let data = JSON.parse(args.data.data);
assert.strictEqual(args.url, '/web/list/export_xls',
"should call get_file with the correct url");
assert.deepEqual(data.columns, [
{field: "foo", string: "Foo"},
{field: "bar", string: "Bar"},
{field: "int_field", aggregateValue: 32, string: "int_field"},
{field: "datetime", string: "Datetime Field"},
{field: "amount", string: "Monetary field"},
{field: "o2m", string: "O2M field"},
{field: "m2o", string: "M2O field"},
{field: "m2m", string: "M2M field"},
], "columns should be in the correct order with the string and aggregate value")
assert.deepEqual(data.groups[0], {
isGrouped: false,
count: 4,
aggregateValues: {},
hideHeader: true,
value: "Undefined",
data: [
{foo: "yop", bar: "True", int_field: "10", datetime: "12/12/2016 10:55:05", amount: "1200.00€", m2m: "2 records", m2o: "Value 1", o2m: "No records"},
{foo: "blip", bar: "True", int_field: "9", datetime: "", amount: "$500.00", m2m: "3 records", m2o: "Value 2", o2m: "No records"},
{foo: "gnap", bar: "True", int_field: "17", datetime: "", amount: "$300.00", m2m: "No records", m2o: "Value 1", o2m: "No records"},
{foo: "blip", bar: "False", int_field: "-4", datetime: "", amount: "$0.00", m2m: "1 record", m2o: "Value 1", o2m: "No records"},
],
});
args.complete();
},
},
});
// Download
await testUtils.dom.click(list.$buttons.find('.o_list_download'));
list.destroy();
});
QUnit.test('grouped list on selection field at level 2', async function (assert) {
assert.expect(4);
@@ -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();
});
});
});
+1 -1
View File
@@ -6,4 +6,4 @@ from . import test_js
from . import test_menu
from . import test_serving_base
from . import test_click_everywhere
from . import test_list_controller
from . import test_export
+176
View File
@@ -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")
-75
View File
@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ..controllers.list_controller import TableListExporter
from odoo.tests.common import HttpCase
class WorksheetMock(dict):
def write(self, x, y, value, style=None):
if (x, y) in self:
raise Exception("Cannot rewrite cell [{}, {}]".format(x, y))
self[x, y] = value
class ListController(HttpCase):
def test_list_download(self):
controller = TableListExporter()
columns = [
{'field': "foo", 'string': "Foo"},
{'field': "bar", 'string': "Bar"},
{'field': "int_field", 'aggregateValue': 19, 'string': "int_field"},
]
groups = [{
# Group "yop"
'isGrouped': True,
'count': 1,
'aggregateValues': {'int_field': 19},
'hideHeader': False,
'value': "yop",
'data': [{
# Group "yop > true"
'isGrouped': False,
'count': 1,
'aggregateValues': {'int_field': 10},
'hideHeader': False,
'value': "true",
'data': [{'foo': "yop", 'bar': "True", 'int_field': 10}],
}, {
# Group "yop > false"
'isGrouped': False,
'count': 10,
'aggregateValues': {'int_field': 9},
'hideHeader': False,
'value': "false",
'data': [],
}],
}]
worksheet = WorksheetMock()
controller._write_worksheet(worksheet, {'columns': columns, 'groups': groups})
# Header
self.assertEqual(worksheet[0, 0], 'Foo')
self.assertEqual(worksheet[0, 1], 'Bar')
self.assertEqual(worksheet[0, 2], 'int_field')
# Main group
self.assertEqual(worksheet[1, 0], 'yop (1)', "Group header")
self.assertEqual(worksheet[1, 2], 19, "int_field aggregate value")
# First sub-group (open)
self.assertEqual(worksheet[2, 0], '>true (1)', "subgroup header")
self.assertEqual(worksheet[2, 2], 10, "int_field aggregate value")
# data
self.assertEqual(worksheet[3, 0], 'yop')
self.assertEqual(worksheet[3, 1], 'True')
self.assertEqual(worksheet[3, 2], 10)
# second sub-group (closed)
self.assertEqual(worksheet[4, 0], '>false (10)', "second sub-group header")
self.assertEqual(worksheet[4, 2], 9, "int_field aggregate value")
self.assertEqual(worksheet[5, 2], 19, "main list int_field aggregate value")
+1 -1
View File
@@ -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``
+1 -1
View File
@@ -27,7 +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="download"/></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 -->