diff --git a/odoo/addons/test_read_group/ir.model.access.csv b/odoo/addons/test_read_group/ir.model.access.csv index 359aa22dc15..c433899c782 100644 --- a/odoo/addons/test_read_group/ir.model.access.csv +++ b/odoo/addons/test_read_group/ir.model.access.csv @@ -6,3 +6,5 @@ access_test_read_group_on_selection,access_test_read_group_on_selection,model_te access_test_read_group_fill_temporal,access_test_read_group_fill_temporal,model_test_read_group_fill_temporal,,1,0,0,0 access_test_read_group_order,access_test_read_group_order,model_test_read_group_order,,1,0,0,0 access_test_read_group_order_line,access_test_read_group_order_line,model_test_read_group_order_line,,1,0,0,0 +access_test_read_group_user,access_test_read_group_user,model_test_read_group_user,,1,0,0,0 +access_test_read_group_task,access_test_read_group_task,model_test_read_group_task,,1,0,0,0 diff --git a/odoo/addons/test_read_group/models.py b/odoo/addons/test_read_group/models.py index d1a2a37e481..84c5f775129 100644 --- a/odoo/addons/test_read_group/models.py +++ b/odoo/addons/test_read_group/models.py @@ -65,3 +65,31 @@ class OrderLine(models.Model): order_id = fields.Many2one('test_read_group.order', ondelete='cascade') value = fields.Integer() + + +class User(models.Model): + _name = 'test_read_group.user' + _description = "User" + + name = fields.Char(required=True) + task_ids = fields.Many2many( + 'test_read_group.task', + 'test_read_group_task_user_rel', + 'user_id', + 'task_id', + string="Tasks", + ) + + +class Task(models.Model): + _name = 'test_read_group.task' + _description = "Project task" + + name = fields.Char(required=True) + user_ids = fields.Many2many( + 'test_read_group.user', + 'test_read_group_task_user_rel', + 'task_id', + 'user_id', + string="Collaborators", + ) diff --git a/odoo/addons/test_read_group/tests/__init__.py b/odoo/addons/test_read_group/tests/__init__.py index f97adcd1bd8..45d249904cc 100644 --- a/odoo/addons/test_read_group/tests/__init__.py +++ b/odoo/addons/test_read_group/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_group_expand from . import test_group_operator from . import test_fill_temporal from . import test_auto_join +from . import test_m2m_grouping diff --git a/odoo/addons/test_read_group/tests/test_m2m_grouping.py b/odoo/addons/test_read_group/tests/test_m2m_grouping.py new file mode 100644 index 00000000000..23b803dee51 --- /dev/null +++ b/odoo/addons/test_read_group/tests/test_m2m_grouping.py @@ -0,0 +1,228 @@ +""" Test read_group grouping with many2many fields """ + + +from odoo.fields import Command +from odoo.tests import common + + +@common.tagged('test_m2m_read_group') +class TestM2MGrouping(common.TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.users = cls.env['test_read_group.user'].create([ + {'name': 'Mario'}, + {'name': 'Luigi'}, + ]) + cls.tasks = cls.env['test_read_group.task'].create([ + { # both users + 'name': "Super Mario Bros.", + 'user_ids': [Command.set(cls.users.ids)], + }, + { # mario only + 'name': "Paper Mario", + 'user_ids': [Command.set(cls.users[0].ids)], + }, + { # luigi only + 'name': "Luigi's Mansion", + 'user_ids': [Command.set(cls.users[1].ids)], + }, + { # no user + 'name': 'Donkey Kong', + 'user_ids': [Command.set([])], + }, + ]) + + def test_base_users(self): + # group users + user_by_tasks = self.users.read_group( + domain=[], + fields=['name:array_agg'], + groupby=['task_ids'], + ) + self.assertEqual(user_by_tasks, [ + { # first task: both users + 'task_ids': self.tasks[0].id, + 'task_ids_count': 2, + 'name': ['Mario', 'Luigi'], + '__domain': [('task_ids', '=', self.tasks[0].id)], + }, + { # second task: Mario only + 'task_ids': self.tasks[1].id, + 'task_ids_count': 1, + 'name': ['Mario'], + '__domain': [('task_ids', '=', self.tasks[1].id)], + }, + { # third task: Luigi only + 'task_ids': self.tasks[2].id, + 'task_ids_count': 1, + 'name': ['Luigi'], + '__domain': [('task_ids', '=', self.tasks[2].id)], + }, + ]) + + def test_base_tasks(self): + # consider the simplest case first: one task with two users + task_by_users = self.tasks.read_group( + domain=[('id', '=', self.tasks[0].id)], + fields=['name:array_agg'], + groupby=['user_ids'], + ) + self.assertEqual(task_by_users, [ + { # task of Mario + 'user_ids': self.users[0].id, + 'user_ids_count': 1, + 'name': ["Super Mario Bros."], + '__domain': ['&', ('user_ids', '=', self.users[0].id), ('id', '=', self.tasks[0].id)], + }, + { # task of Luigi + 'user_ids': self.users[1].id, + 'user_ids_count': 1, + 'name': ["Super Mario Bros."], + '__domain': ['&', ('user_ids', '=', self.users[1].id), ('id', '=', self.tasks[0].id)], + }, + ]) + + # now consider the full case: all tasks, with all user combinations + task_by_users = self.tasks.read_group( + domain=[], + fields=['name:array_agg'], + groupby=['user_ids'], + ) + self.assertEqual(task_by_users, [ + { # tasks of Mario + 'user_ids': self.users[0].id, + 'user_ids_count': 2, + 'name': unordered(["Super Mario Bros.", "Paper Mario"]), + '__domain': [('user_ids', '=', self.users[0].id)], + }, + { # tasks of Luigi + 'user_ids': self.users[1].id, + 'user_ids_count': 2, + 'name': unordered(["Super Mario Bros.", "Luigi's Mansion"]), + '__domain': [('user_ids', '=', self.users[1].id)], + }, + { # tasks of nobody + 'user_ids': False, + 'user_ids_count': 1, + 'name': unordered(["Donkey Kong"]), + '__domain': [('user_ids', '=', False)], + }, + ]) + + # check that the domain returned by read_group is valid + tasks_from_domain = self.tasks.search(task_by_users[0]['__domain']) + self.assertEqual(tasks_from_domain, self.tasks[:2]) + + tasks_from_domain = self.tasks.search(task_by_users[1]['__domain']) + self.assertEqual(tasks_from_domain, self.tasks[0] + self.tasks[2]) + + tasks_from_domain = self.tasks.search(task_by_users[2]['__domain']) + self.assertEqual(tasks_from_domain, self.tasks[3]) + + def test_complex_case(self): + # group tasks with some ir.rule on users + users_model = self.env['ir.model']._get(self.users._name) + self.env['ir.rule'].create({ + 'name': "Only The Lone Wanderer allowed", + 'model_id': users_model.id, + 'domain_force': [('id', '=', self.users[0].id)], + }) + + # as superuser, ir.rule should not apply + expected = """ + SELECT + min("test_read_group_task".id) AS id, + count("test_read_group_task".id) AS "user_ids_count", + array_agg("test_read_group_task"."name") AS "name", + "test_read_group_task__user_ids"."user_id" AS "user_ids" + FROM "test_read_group_task" + LEFT JOIN "test_read_group_task_user_rel" AS "test_read_group_task__user_ids" + ON ("test_read_group_task"."id" = "test_read_group_task__user_ids"."task_id") + GROUP BY "test_read_group_task__user_ids"."user_id" + ORDER BY "user_ids" + """ + with self.assertQueries([expected]): + as_admin = self.tasks.read_group( + domain=[], + fields=['name:array_agg'], + groupby=['user_ids'], + ) + self.assertEqual(as_admin, [ + { # tasks of Mario + 'user_ids': self.users[0].id, + 'user_ids_count': 2, + 'name': unordered(["Super Mario Bros.", "Paper Mario"]), + '__domain': [('user_ids', '=', self.users[0].id)], + }, + { # tasks of Luigi + 'user_ids': self.users[1].id, + 'user_ids_count': 2, + 'name': unordered(["Super Mario Bros.", "Luigi's Mansion"]), + '__domain': [('user_ids', '=', self.users[1].id)], + }, + { # tasks of nobody + 'user_ids': False, + 'user_ids_count': 1, + 'name': unordered(["Donkey Kong"]), + '__domain': [('user_ids', '=', False)], + }, + ]) + + # as demo user, ir.rule should apply + tasks = self.tasks.with_user(self.browse_ref('base.user_demo')) + + # warming up various caches; this avoids extra queries + tasks.read_group(domain=[], fields=['name:array_agg'], groupby=['user_ids']) + + expected = """ + SELECT + min("test_read_group_task".id) AS id, + count("test_read_group_task".id) AS "user_ids_count", + array_agg("test_read_group_task"."name") AS "name", + "test_read_group_task__user_ids"."user_id" AS "user_ids" + FROM "test_read_group_task" + LEFT JOIN "test_read_group_task_user_rel" AS "test_read_group_task__user_ids" + ON ( + "test_read_group_task"."id" = "test_read_group_task__user_ids"."task_id" + AND "test_read_group_task__user_ids"."user_id" IN ( + SELECT "test_read_group_user".id + FROM "test_read_group_user" + WHERE ("test_read_group_user"."id" = %s) + ) + ) + GROUP BY "test_read_group_task__user_ids"."user_id" + ORDER BY "user_ids" + """ + with self.assertQueries([expected]): + as_demo = tasks.read_group( + domain=[], + fields=['name:array_agg'], + groupby=['user_ids'], + ) + self.assertEqual(as_demo, [ + { # tasks of Mario + 'user_ids': self.users[0].id, + 'user_ids_count': 2, + 'name': unordered(['Super Mario Bros.', 'Paper Mario']), + '__domain': [('user_ids', '=', self.users[0].id)], + }, + { # tasks of Luigi and no user + 'user_ids': False, + 'user_ids_count': 2, + 'name': unordered(["Luigi's Mansion", 'Donkey Kong']), + '__domain': [('user_ids', '=', False)], + }, + ]) + + +class unordered(list): + """ A list where equality is interpreted without ordering. """ + __slots__ = () + + def __eq__(self, other): + return sorted(self) == sorted(other) + + def __ne__(self, other): + return sorted(self) != sorted(other) diff --git a/odoo/fields.py b/odoo/fields.py index 38bf1396ef0..fcca4a323fe 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -589,6 +589,13 @@ class Field(MetaField('DummyField', (object,), {})): """ Return the base field of an inherited field, or ``self``. """ return self.inherited_field.base_field if self.inherited_field else self + @property + def groupable(self): + """ + Return whether the field may be used for grouping in :meth:`~odoo.models.BaseModel.read_group`. + """ + return self.store and self.column_type + # # Company-dependent fields # @@ -3637,6 +3644,10 @@ class Many2many(_RelationalMulti): model, self._module, ) + @property + def groupable(self): + return self.store + def read(self, records): context = {'active_test': False} context.update(self.context) diff --git a/odoo/models.py b/odoo/models.py index 0f63b683e03..8c5e9c37239 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2044,7 +2044,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): def _read_group_prepare(self, orderby, aggregated_fields, annotated_groupbys, query): """ Prepares the GROUP BY and ORDER BY terms for the read_group method. Adds the missing JOIN clause - to the query if order should be computed against m2o field. + to the query if order should be computed against m2o field. :param orderby: the orderby definition in the form "%(field)s %(order)s" :param aggregated_fields: list of aggregated fields in the query :param annotated_groupbys: list of dictionaries returned by _read_group_process_groupby @@ -2141,9 +2141,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): return { 'field': split[0], 'groupby': gb, - 'type': field_type, + 'type': field_type, 'display_format': display_formats[gb_function or 'month'] if temporal else None, - 'interval': time_intervals[gb_function or 'month'] if temporal else None, + 'interval': time_intervals[gb_function or 'month'] if temporal else None, 'tz_convert': tz_convert, 'qualified_field': qualified_field, } @@ -2307,8 +2307,10 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): self._apply_ir_rules(query, 'read') for gb in groupby_fields: assert gb in self._fields, "Unknown field %r in 'groupby'" % gb - gb_field = self._fields[gb].base_field - assert gb_field.store and gb_field.column_type, "Fields in 'groupby' must be regular database-persisted fields (no function or related fields), or function fields with store=True" + assert self._fields[gb].base_field.groupable, ( + f"Field {gb} of model {self._name} is not a stored field, only stored fields " + "(regular or many2many) are valid for the 'groupby' parameter." + ) aggregated_fields = [] select_terms = [] @@ -2416,7 +2418,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # Right now, read_group only fill results in lazy mode (by default). # If you need to have the empty groups in 'eager' mode, then the # method _read_group_fill_results need to be completely reimplemented - # in a sane way + # in a sane way result = self._read_group_fill_results( domain, groupby_fields[0], groupby[len(annotated_groupbys):], aggregated_fields, count_field, result, read_group_order=order, @@ -2468,9 +2470,32 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): alias, parent_fname, parent_model._table, 'id', parent_fname, ) model, alias, field = parent_model, parent_alias, field.related_field - # handle the case where the field is translated - if field.translate is True: + + if field.type == 'many2many': + # special case for many2many fields: prepare a query on the comodel + # in order to reuse the mechanism _apply_ir_rules, then inject the + # query as an extra condition of the left join + comodel = self.env[field.comodel_name] + subquery = Query(self.env.cr, comodel._table) + comodel._apply_ir_rules(subquery) + # add the extra join condition only if there is an actual subquery + extra, extra_params = None, () + if subquery.where_clause: + subquery_str, extra_params = subquery.select() + extra = '"{rhs}"."%s" IN (%s)' % (field.column2, subquery_str) + # LEFT JOIN field_relation ON + # alias.id = field_relation.field_column1 + # AND field_relation.field_column2 IN (subquery) + rel_alias = query.left_join( + alias, 'id', field.relation, field.column1, field.name, + extra=extra, extra_params=extra_params, + ) + return '"%s"."%s"' % (rel_alias, field.column2) + + elif field.translate is True: + # handle the case where the field is translated return model._generate_translated_field(alias, fname, query) + else: return '"%s"."%s"' % (alias, fname)