[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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import test_export
|
||||
|
||||
@@ -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' ,''],
|
||||
])
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user