From 645ae69bb94bb810c7afc031d9eee9e89784a3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre?= Date: Thu, 10 Oct 2019 11:08:51 +0200 Subject: [PATCH] [FIX] web: Correctly aggregate values in exported parent groups When exporting a grouped list view with some nested groups, the aggregate value of parent groups are not correct. It always sums aggregated values of children whether the group operator is 'sum' or not (could be 'max', 'avg', ...). This behavior is wrong and can even lead to a crash if the aggregated field is a date field (e.g. with group_operator='max'). (Try two sum two dates...) The quick fix 85cf47f was merged just before OXP to avoid any crash. This fix limited the support of aggregates to only int and float fields. This commit remove this limitation. This commit correclty implements the aggregation for parent group for all field types and all group_operator. This commit also improves the export feature tests. X-original-commit: 5e7e4fa98698967e3c4fd0903f4aa8e91981a6cd --- addons/test_xlsx_export/__init__.py | 1 + addons/test_xlsx_export/__manifest__.py | 12 + addons/test_xlsx_export/ir.model.access.csv | 3 + addons/test_xlsx_export/models.py | 34 ++ addons/test_xlsx_export/tests/__init__.py | 4 + addons/test_xlsx_export/tests/test_export.py | 341 +++++++++++++++++++ addons/web/controllers/main.py | 104 +++++- addons/web/tests/__init__.py | 1 - addons/web/tests/test_export.py | 176 ---------- 9 files changed, 486 insertions(+), 190 deletions(-) create mode 100644 addons/test_xlsx_export/__init__.py create mode 100644 addons/test_xlsx_export/__manifest__.py create mode 100644 addons/test_xlsx_export/ir.model.access.csv create mode 100644 addons/test_xlsx_export/models.py create mode 100644 addons/test_xlsx_export/tests/__init__.py create mode 100644 addons/test_xlsx_export/tests/test_export.py delete mode 100644 addons/web/tests/test_export.py diff --git a/addons/test_xlsx_export/__init__.py b/addons/test_xlsx_export/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/addons/test_xlsx_export/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/test_xlsx_export/__manifest__.py b/addons/test_xlsx_export/__manifest__.py new file mode 100644 index 00000000000..1459d18a3dc --- /dev/null +++ b/addons/test_xlsx_export/__manifest__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'test xlsx export', + 'version': '0.1', + 'category': 'Tests', + 'description': """A module to test xlsx export.""", + 'depends': ['web', 'test_mail'], + 'data': ['ir.model.access.csv'], + 'installable': True, + 'auto_install': False, +} diff --git a/addons/test_xlsx_export/ir.model.access.csv b/addons/test_xlsx_export/ir.model.access.csv new file mode 100644 index 00000000000..c142abbcd12 --- /dev/null +++ b/addons/test_xlsx_export/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_export_group_operator,access_export_group_operator,model_export_group_operator,,1,1,1,1 +access_export_group_operator_one2many,access_export_group_operator_one2many,model_export_group_operator_one2many,,1,1,1,1 diff --git a/addons/test_xlsx_export/models.py b/addons/test_xlsx_export/models.py new file mode 100644 index 00000000000..99d96ba63cb --- /dev/null +++ b/addons/test_xlsx_export/models.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + +class NewModel(models.Model): + _name = 'export.integer' + _description = 'Export: Integer' + + value = fields.Integer(default=4) + + def name_get(self): + return [(record.id, "%s:%s" % (self._name, record.value)) for record in self] + +class GroupOperator(models.Model): + _name = 'export.group_operator' + _description = 'Export Group Operator' + + int_sum = fields.Integer(group_operator='sum') + int_max = fields.Integer(group_operator='max') + float_min = fields.Float(group_operator='min') + float_avg = fields.Float(group_operator='avg') + date_max = fields.Date(group_operator='max') + bool_and = fields.Boolean(group_operator='bool_and') + bool_or = fields.Boolean(group_operator='bool_or') + many2one = fields.Many2one('export.integer') + one2many = fields.One2many('export.group_operator.one2many', 'parent_id') + +class GroupOperatorO2M(models.Model): + _name = 'export.group_operator.one2many' + _description = 'Export Group Operator One2Many' + + parent_id = fields.Many2one('export.group_operator') + value = fields.Integer() diff --git a/addons/test_xlsx_export/tests/__init__.py b/addons/test_xlsx_export/tests/__init__.py new file mode 100644 index 00000000000..ff41d3a8d95 --- /dev/null +++ b/addons/test_xlsx_export/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_export + diff --git a/addons/test_xlsx_export/tests/test_export.py b/addons/test_xlsx_export/tests/test_export.py new file mode 100644 index 00000000000..8aeeb45cbd0 --- /dev/null +++ b/addons/test_xlsx_export/tests/test_export.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +from datetime import date +from unittest.mock import patch + +from odoo import http +from odoo.tests import common +from odoo.addons.web.controllers.main import ExportXlsxWriter +from odoo.addons.test_mail.tests.common import mail_new_test_user + + +class XlsxCreatorCase(common.HttpCase): + model_name = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = None + + def setUp(self): + super().setUp() + self.model = self.env[self.model_name] + + mail_new_test_user(self.env, login='fof', password='123456789') + self.authenticate('fof', '123456789') + + self.worksheet = {} # mock worksheet + + self.default_params = { + 'domain': [], + 'fields': [{'name': field.name, 'label': field.string} for field in self.model._fields.values()], + 'groupby': [], + 'ids': False, + 'import_compat': False, + 'model': self.model._name, + } + + def _mock_write(self, row, column, value, style=None): + self.worksheet[row, column] = str(value) + + def make(self, values, context=None): + return self.model.with_context(**(context or {})).create(values) + + def export(self, values, fields=[], params={}, context=None): + self.worksheet = {} + self.make(values, context=context) + + if fields and 'fields' not in params: + params['fields'] = [{ + 'name': self.model._fields[f].name, + 'label': self.model._fields[f].string, + } for f in fields] + + with patch.object(ExportXlsxWriter, 'write', self._mock_write): + self.url_open('/web/export/xlsx', data={ + 'data': json.dumps(dict(self.default_params, **params)), + 'token': 'dummy', + 'csrf_token': http.WebRequest.csrf_token(self), + }) + return self.worksheet + + def assertExportEqual(self, value, expected): + for row in range(len(expected)): + for column in range(len(expected[row])): + cell_value = value.pop((row, column), '') + expected_value = expected[row][column] + self.assertEqual(cell_value, expected_value, "Cell %s, %s have a wrong value" % (row, column)) + self.assertFalse(value, "There are unexpected cells in the export") + + +class TestGroupedExport(XlsxCreatorCase): + model_name = 'export.group_operator' + + def test_int_sum_max(self): + values = [ + {'int_sum': 10, 'int_max': 20}, + {'int_sum': 10, 'int_max': 50}, + {'int_sum': 20,'int_max': 30}, + ] + export = self.export(values, fields=['int_sum', 'int_max'], params={'groupby': ['int_sum', 'int_max']}) + self.assertExportEqual(export, [ + ['Int Sum' ,'Int Max'], + ['10 (2)' ,'50'], + [' 20 (1)' ,'20'], + ['10' ,'20'], + [' 50 (1)' ,'50'], + ['10' ,'50'], + ['20 (1)' ,'30'], + [' 30 (1)' ,'30'], + ['20' ,'30'], + ]) + + export = self.export([], fields=['int_max', 'int_sum'], params={'groupby': ['int_sum', 'int_max']}) + + self.assertExportEqual(export, [ + ['Int Max' ,'Int Sum'], + ['10 (2)' ,'20'], + [' 20 (1)' ,'10'], + ['20' ,'10'], + [' 50 (1)' ,'10'], + ['50' ,'10'], + ['20 (1)' ,'20'], + [' 30 (1)' ,'20'], + ['30' ,'20'], + ]) + + def test_float_min(self): + values = [ + {'int_sum': 10, 'float_min': 111.0}, + {'int_sum': 10, 'float_min': 222.0}, + {'int_sum': 20, 'float_min': 333.0}, + ] + export = self.export(values, fields=['int_sum', 'float_min'], params={'groupby': ['int_sum', 'float_min']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Min'], + ['10 (2)' ,'111.0'], + [' 111.0 (1)','111.0'], + ['10' ,'111.0'], + [' 222.0 (1)','222.0'], + ['10' ,'222.0'], + ['20 (1)' ,'333.0'], + [' 333.0 (1)','333.0'], + ['20' ,'333.0'], + ]) + + def test_float_avg(self): + values = [ + {'int_sum': 10, 'float_avg': 100.0}, + {'int_sum': 10, 'float_avg': 200.0}, + {'int_sum': 20, 'float_avg': 300.0}, + ] + export = self.export(values, fields=['int_sum', 'float_avg'], params={'groupby': ['int_sum', 'float_avg']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Avg'], + ['10 (2)' ,'150.0'], + [' 100.0 (1)','100.0'], + ['10' ,'100.0'], + [' 200.0 (1)','200.0'], + ['10' ,'200.0'], + ['20 (1)' ,'300.0'], + [' 300.0 (1)','300.0'], + ['20' ,'300.0'], + ]) + + def test_float_avg_nested(self): + """ With more than one nested level (avg aggregation) """ + values = [ + {'int_sum': 10, 'int_max': 30, 'float_avg': 100.0}, + {'int_sum': 10, 'int_max': 30, 'float_avg': 200.0}, + {'int_sum': 10, 'int_max': 20, 'float_avg': 600.0}, + ] + export = self.export(values, fields=['int_sum', 'float_avg'], params={'groupby': ['int_sum', 'int_max', 'float_avg']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Avg'], + ['10 (3)' ,'300.0'], + [' 20 (1)' ,'600.0'], + [' 600.0 (1)','600.0'], + ['10' ,'600.0'], + [' 30 (2)' ,'150.0'], + [' 100.0 (1)','100.0'], + ['10' ,'100.0'], + [' 200.0 (1)','200.0'], + ['10' ,'200.0'], + ]) + + def test_float_avg_nested_no_value(self): + """ With more than one nested level (avg aggregation is done on 0, not False) """ + values = [ + {'int_sum': 10, 'int_max': 20, 'float_avg': False}, + {'int_sum': 10, 'int_max': 30, 'float_avg': False}, + {'int_sum': 10, 'int_max': 30, 'float_avg': False}, + ] + export = self.export(values, fields=['int_sum', 'float_avg'], params={'groupby': ['int_sum', 'int_max', 'float_avg']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Avg'], + ['10 (3)' ,'0.0'], + [' 20 (1)' ,'0.0'], + [' Undefined (1)','0.0'], + ['10' ,'0.0'], + [' 30 (2)' ,'0.0'], + [' Undefined (2)','0.0'], + ['10' ,'0.0'], + ['10' ,'0.0'], + ]) + + def test_date_max(self): + values = [ + {'int_sum': 10, 'date_max': date(2019, 1, 1)}, + {'int_sum': 10, 'date_max': date(2000, 1, 1)}, + {'int_sum': 20, 'date_max': date(1980, 1, 1)}, + ] + export = self.export(values, fields=['int_sum', 'date_max'], params={'groupby': ['int_sum', 'date_max:month']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Date Max'], + ['10 (2)' ,'2019-01-01'], + [' January 2000 (1)' ,'2000-01-01'], + ['10' ,'2000-01-01'], + [' January 2019 (1)' ,'2019-01-01'], + ['10' ,'2019-01-01'], + ['20 (1)' ,'1980-01-01'], + [' January 1980 (1)' ,'1980-01-01'], + ['20' ,'1980-01-01'], + ]) + + def test_bool_and(self): + values = [ + {'int_sum': 10, 'bool_and': True}, + {'int_sum': 10, 'bool_and': True}, + {'int_sum': 20, 'bool_and': True}, + {'int_sum': 20, 'bool_and': False}, + ] + export = self.export(values, fields=['int_sum', 'bool_and'], params={'groupby': ['int_sum', 'bool_and']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Bool And'], + ['10 (2)' ,'True'], + [' True (2)' ,'True'], + ['10' ,'True'], + ['10' ,'True'], + ['20 (2)' ,'False'], + [' False (1)' ,'False'], + ['20' ,'False'], + [' True (1)' ,'True'], + ['20' ,'True'], + ]) + + def test_bool_or(self): + values = [ + {'int_sum': 10, 'bool_or': True}, + {'int_sum': 10, 'bool_or': False}, + {'int_sum': 20, 'bool_or': False}, + {'int_sum': 20, 'bool_or': False}, + ] + export = self.export(values, fields=['int_sum', 'bool_or'], params={'groupby': ['int_sum', 'bool_or']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Bool Or'], + ['10 (2)' ,'True'], + [' False (1)' ,'False'], + ['10' ,'False'], + [' True (1)' ,'True'], + ['10' ,'True'], + ['20 (2)' ,'False'], + [' False (2)' ,'False'], + ['20' ,'False'], + ['20' ,'False'], + ]) + + def test_many2one(self): + values = [ + {'int_sum': 10, 'many2one': self.env['export.integer'].create({}).id}, + {'int_sum': 10}, + ] + export = self.export(values, fields=['int_sum', 'many2one'], params={'groupby': ['int_sum', 'many2one']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Many2One'], + ['10 (2)' ,''], + [' export.integer:4 (1)' ,''], + ['10' ,'export.integer:4'], + [' Undefined (1)' ,''], + ['10' ,'False'], + ]) + + def test_nested_records(self): + """ + aggregated values currently not supported for nested record export, but it should not crash + e.g. export 'many2one/const' + """ + values = [{'int_sum': 10, + 'date_max': date(2019, 1, 1), + 'many2one': self.env['export.integer'].create({}).id, + }, { + 'int_sum': 10, + 'date_max': date(2000, 1, 1), + 'many2one': self.env['export.integer'].create({}).id, + },] + export = self.export(values, + params={ + 'groupby': ['int_sum', 'date_max:month'], + 'fields': [ + {'name': 'int_sum', 'label': 'Int Sum'}, + {'name': 'date_max', 'label': 'Date Max'}, + {'name': 'many2one/value', 'label': 'Many2One/Value'}, + ] + }) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Date Max' ,'Many2One/Value'], + ['10 (2)' ,'2019-01-01' ,''], + [' January 2000 (1)' ,'2000-01-01' ,''], + ['10' ,'2000-01-01' ,'4'], + [' January 2019 (1)' ,'2019-01-01' ,''], + ['10' ,'2019-01-01' ,'4'], + ]) + + def test_one2many(self): + values = [{ + 'int_sum': 10, + 'one2many': [ + (0, 0, {'value': 8}), + (0, 0, {'value': 9}), + ], + }] + export = self.export(values, + params={ + 'groupby': ['int_sum',], + 'fields': [ + {'name': 'int_sum', 'label': 'Int Sum'}, + {'name': 'one2many/value', 'label': 'One2many/Value'}, + ] + }) + self.assertExportEqual(export, [ + ['Int Sum' ,'One2many/Value'], + ['10 (1)' ,''], + ['10' ,'8'], + ['' ,'9'], + ]) + + def test_unset_date_values(self): + values = [ + {'int_sum': 10, 'date_max': date(2019, 1, 1)}, + {'int_sum': 10, 'date_max': False}, + ] + # Group and aggregate by date, but date fields are not set for all records + export = self.export(values, fields=['int_sum', 'date_max'], params={'groupby': ['int_sum', 'date_max:month']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Date Max'], + ['10 (2)' ,'2019-01-01'], + [' January 2019 (1)' ,'2019-01-01'], + ['10' ,'2019-01-01'], + [' Undefined (1)' ,''], + ['10' ,''], + ]) diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 165baa693b5..133421caa98 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -35,7 +35,7 @@ import odoo import odoo.modules.registry from odoo.api import call_kw, Environment from odoo.modules import get_module_path, get_resource_path -from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs +from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property from odoo.tools.mimetypes import guess_mimetype from odoo.tools.translate import _ from odoo.tools.misc import str2bool, xlsxwriter, file_open @@ -64,6 +64,37 @@ DBNAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9_.-]+$' COMMENT_PATTERN = r'Modified by [\s\w\-.]+ from [\s\w\-.]+' + +def none_values_filtered(func): + @functools.wraps(func) + def wrap(iterable): + return func(v for v in iterable if v is not None) + return wrap + +def allow_empty_iterable(func): + """ + Some functions do not accept empty iterables (e.g. max, min with no default value) + This returns the function `func` such that it returns None if the iterable + is empty instead of raising a ValueError. + """ + @functools.wraps(func) + def wrap(iterable): + iterator = iter(iterable) + try: + value = next(iterator) + return func(itertools.chain([value], iterator)) + except StopIteration: + return None + return wrap + +OPERATOR_MAPPING = { + 'max': none_values_filtered(allow_empty_iterable(max)), + 'min': none_values_filtered(allow_empty_iterable(min)), + 'sum': sum, + 'bool_and': all, + 'bool_or': any, +} + #---------------------------------------------------------- # Odoo Web helpers #---------------------------------------------------------- @@ -591,12 +622,67 @@ class GroupsTreeNode: self._groupby_type = groupby_type 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 _get_aggregate(self, field_name, data, group_operator): + # When exporting one2many fields, multiple data lines might be exported for one record. + # Blank cells of additionnal lines are filled with an empty string. This could lead to '' being + # aggregated with an integer or float. + data = (value for value in data if value != '') + + if group_operator == 'avg': + return self._get_avg_aggregate(field_name, data) + + aggregate_func = OPERATOR_MAPPING.get(group_operator) + if not aggregate_func: + _logger.warning("Unsupported export of group_operator '%s' for field %s on model %s" % (group_operator, field_name, self._model._name)) + return + + if self.data: + return aggregate_func(data) + return aggregate_func((child.aggregated_values.get(field_name) for child in self.children.values())) + + def _get_avg_aggregate(self, field_name, data): + aggregate_func = OPERATOR_MAPPING.get('sum') + if self.data: + return aggregate_func(data) / self.count + children_sums = (child.aggregated_values.get(field_name) * child.count for child in self.children.values()) + return aggregate_func(children_sums) / self.count + + def _get_aggregated_field_names(self): + """ Return field names of exported field having a group operator """ + aggregated_field_names = [] + for field_name in self._fields: + if '/' in field_name: + # Currently no support of aggregated value for nested record fields + # e.g. line_ids/analytic_line_ids/amount + continue + field = self._model._fields[field_name] + if field.group_operator: + aggregated_field_names.append(field_name) + return aggregated_field_names + + # Lazy property to memoize aggregated values of children nodes to avoid useless recomputations + @lazy_property + def aggregated_values(self): + + aggregated_values = {} + + # Transpose the data matrix to group all values of each field in one iterable + field_values = zip(*self.data) + for field_name in self._fields: + field_data = self.data and next(field_values) or [] + + if field_name in self._get_aggregated_field_names(): + field = self._model._fields[field_name] + aggregated_values[field_name] = self._get_aggregate(field_name, field_data, field.group_operator) + + return aggregated_values + def child(self, key): """ Return the child identified by `key`. @@ -614,17 +700,9 @@ class GroupsTreeNode: 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] + leaf_path = [group.get(groupby_field) for groupby_field in self._groupby] domain = group.pop('__domain') count = group.pop('__count') - aggregated_values = group - - keys = list(aggregated_values) - for key in keys: - if not isinstance(aggregated_values[key], (int, float)): - del aggregated_values[key] records = self._model.search(domain, offset=0, limit=False, order=False) @@ -637,7 +715,6 @@ class GroupsTreeNode: 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',[]) @@ -737,7 +814,8 @@ class GroupExportXlsxWriter(ExportXlsxWriter): 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) + aggregated_value = aggregates.get(field['name']) + self.write(row, column, str(aggregated_value if aggregated_value is not None else ''), self.header_bold_style) return row + 1, 0 diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index b479a574e46..1ed329bafe1 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -6,4 +6,3 @@ 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 deleted file mode 100644 index b0fed393e10..00000000000 --- a/addons/web/tests/test_export.py +++ /dev/null @@ -1,176 +0,0 @@ -# -*- 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")