diff --git a/base_partition/README.rst b/base_partition/README.rst new file mode 100644 index 000000000..d66f63daa --- /dev/null +++ b/base_partition/README.rst @@ -0,0 +1,82 @@ +============== +Base Partition +============== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/16.0/base_partition + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_partition + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/server-tools&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a `partition(self, accessor)` method to every model. +It accepts for accessor any parameter that would be accepted by `mapped`, +i.e. a string `"field(.subfield)*"` or a function `(lambda x: not x.b)`. +It returns a dictionary with keys that are equal to `set(record.mapped(accessor))`, +and with values that are recordsets +(these recordsets forming a partition of the initial recordset, conveniently). + +So if we have a recordset (x | y | z ) such that x.f == True, y.f == z.f == False, +then (x | y | z ).partition("f") == {True: x, False: (y | z)}. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Acsone SA/NV + +Contributors +~~~~~~~~~~~~ + +* Nans Lefebvre +* Hughes Damry + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_partition/__init__.py b/base_partition/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/base_partition/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_partition/__manifest__.py b/base_partition/__manifest__.py new file mode 100644 index 000000000..9fa23c695 --- /dev/null +++ b/base_partition/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2020 Acsone (http://www.acsone.eu) +# Nans Lefebvre +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Base Partition", + "summary": "Base module that provide the partition method on all models", + "version": "16.0.1.0.0", + "category": "Uncategorized", + "website": "https://github.com/OCA/server-tools", + "author": "Acsone SA/NV, Odoo Community Association (OCA)", + "license": "LGPL-3", + "installable": True, + "depends": ["base"], +} diff --git a/base_partition/i18n/base_partition.pot b/base_partition/i18n/base_partition.pot new file mode 100644 index 000000000..ab5717e74 --- /dev/null +++ b/base_partition/i18n/base_partition.pot @@ -0,0 +1,30 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_partition +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-04-24 15:38+0000\n" +"PO-Revision-Date: 2023-04-24 15:38+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_partition +#: model:ir.model,name:base_partition.model_base +msgid "Base" +msgstr "" + +#. module: base_partition +#. odoo-python +#: code:addons/base_partition/models/models.py:0 +#, python-format +msgid "" +"Either set up a '_default_batch_size' on the model or provide a batch_size " +"parameter." +msgstr "" diff --git a/base_partition/i18n/fr_BE.pot b/base_partition/i18n/fr_BE.pot new file mode 100644 index 000000000..4befdf2a7 --- /dev/null +++ b/base_partition/i18n/fr_BE.pot @@ -0,0 +1,32 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_partition +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-04-24 15:38+0000\n" +"PO-Revision-Date: 2023-04-24 15:38+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_partition +#: model:ir.model,name:base_partition.model_base +msgid "Base" +msgstr "" + +#. module: base_partition +#. odoo-python +#: code:addons/base_partition/models/models.py:0 +#, python-format +msgid "" +"Either set up a '_default_batch_size' on the model or provide a batch_size " +"parameter." +msgstr "" +"Définir '_default_batch_size' sur le modèle ou fournir une valeur de " +"batch_size en paramètre." diff --git a/base_partition/models/__init__.py b/base_partition/models/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/base_partition/models/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_partition/models/models.py b/base_partition/models/models.py new file mode 100644 index 000000000..4adf275ce --- /dev/null +++ b/base_partition/models/models.py @@ -0,0 +1,64 @@ +# © 2020 Acsone (http://www.acsone.eu) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import _, models +from odoo.exceptions import UserError + + +class Base(models.AbstractModel): + + _inherit = "base" + + def partition(self, accessor): + """Returns a dictionary forming a partition of self into a dictionary + value/recordset for each value obtained from the accessor. + The accessor itself can be either a string that can be passed to mapped, + or an arbitrary function. + Note that it is always at least as fast to pass a function, + hence the current implementation. + If we have a 'field.subfield' accessor such that subfield is not a relational + then the result is a list (not hashable). Then the str(key) are used. + In the general case a value could both not be hashable nor stringifiable, + in a which case this function would crash. + """ + partition = {} + + if isinstance(accessor, str): + if "." not in accessor: + func = lambda r: r[accessor] # noqa: E731 + else: + func = lambda r: r.mapped(accessor) # noqa: E731 + else: + func = accessor + + for record in self: + key = func(record) + if not key.__hash__: + key = str(key) + if key not in partition: + partition[key] = record + else: + partition[key] += record + + return partition + + def batch(self, batch_size=None): + """Yield successive batches of size batch_size, or .""" + if not (batch_size or "_default_batch_size" in dir(self)): + raise UserError( + _( + "Either set up a '_default_batch_size' on the model" + " or provide a batch_size parameter." + ) + ) + batch_size = batch_size or self._default_batch_size + for i in range(0, len(self), batch_size): + yield self[i : i + batch_size] + + def read_per_record(self, fields=None, load="_classic_read"): + result = {} + data_list = self.read(fields=fields, load=load) + for d in data_list: + key = d.pop("id") + result[key] = d + return result diff --git a/base_partition/readme/CONTRIBUTORS.rst b/base_partition/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..e2702f048 --- /dev/null +++ b/base_partition/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Nans Lefebvre +* Hughes Damry diff --git a/base_partition/readme/DESCRIPTION.rst b/base_partition/readme/DESCRIPTION.rst new file mode 100644 index 000000000..bd33c61d6 --- /dev/null +++ b/base_partition/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +This module adds a `partition(self, accessor)` method to every model. +It accepts for accessor any parameter that would be accepted by `mapped`, +i.e. a string `"field(.subfield)*"` or a function `(lambda x: not x.b)`. +It returns a dictionary with keys that are equal to `set(record.mapped(accessor))`, +and with values that are recordsets +(these recordsets forming a partition of the initial recordset, conveniently). + +So if we have a recordset (x | y | z ) such that x.f == True, y.f == z.f == False, +then (x | y | z ).partition("f") == {True: x, False: (y | z)}. diff --git a/base_partition/static/description/icon.png b/base_partition/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/base_partition/static/description/icon.png differ diff --git a/base_partition/static/description/index.html b/base_partition/static/description/index.html new file mode 100644 index 000000000..c65a625bf --- /dev/null +++ b/base_partition/static/description/index.html @@ -0,0 +1,427 @@ + + + + + + +Base Partition + + + +
+

Base Partition

+ + +

Beta License: LGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

This module adds a partition(self, accessor) method to every model. +It accepts for accessor any parameter that would be accepted by mapped, +i.e. a string “field(.subfield)*” or a function (lambda x: not x.b). +It returns a dictionary with keys that are equal to set(record.mapped(accessor)), +and with values that are recordsets +(these recordsets forming a partition of the initial recordset, conveniently).

+

So if we have a recordset (x | y | z ) such that x.f == True, y.f == z.f == False, +then (x | y | z ).partition(“f”) == {True: x, False: (y | z)}.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Acsone SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_partition/tests/__init__.py b/base_partition/tests/__init__.py new file mode 100644 index 000000000..7b173054b --- /dev/null +++ b/base_partition/tests/__init__.py @@ -0,0 +1 @@ +from . import test_partition diff --git a/base_partition/tests/test_partition.py b/base_partition/tests/test_partition.py new file mode 100644 index 000000000..568c4ff6e --- /dev/null +++ b/base_partition/tests/test_partition.py @@ -0,0 +1,134 @@ +# Copyright 2017 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import functools +import math + +from odoo.exceptions import UserError +from odoo.fields import Command +from odoo.tests.common import TransactionCase + + +class TestPartition(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Category = cls.env["res.partner.category"] + cls.c1 = cls.Category.create({"name": "c1"}) + cls.c2 = cls.Category.create({"name": "c2"}) + cls.c3 = cls.Category.create({"name": "c3"}) + + cls.Partner = cls.env["res.partner"] + cls.parent1 = cls.Partner.create({"name": "parent1"}) + cls.parent2 = cls.Partner.create({"name": "parent2"}) + cls.child1 = cls.Partner.create({"name": "child1"}) + cls.child2 = cls.Partner.create({"name": "child2"}) + cls.child3 = cls.Partner.create({"name": "child3"}) + cls.x = cls.Partner.create( + { + "name": "x", + "employee": True, + "category_id": [Command.set([cls.c1.id, cls.c2.id])], + "child_ids": [Command.set([cls.child1.id, cls.child2.id])], + "parent_id": cls.parent1.id, + } + ) + cls.y = cls.Partner.create( + { + "name": "y", + "employee": False, + "category_id": [Command.set([cls.c2.id, cls.c3.id])], + "child_ids": [Command.set([cls.child2.id, cls.child3.id])], + "parent_id": cls.parent2.id, + } + ) + cls.z = cls.Partner.create( + { + "name": "z", + "employee": False, + "category_id": [Command.set([cls.c1.id, cls.c3.id])], + "child_ids": [Command.set([cls.child1.id, cls.child3.id])], + "parent_id": cls.parent2.id, + } + ) + cls.xyz = cls.x + cls.y + cls.z + + def test_partition_many2many(self): + self.partition_field_test("category_id") + + def test_partition_many2one(self): + self.partition_field_test("parent_id") + + def test_partition_one2many(self): + self.partition_field_test("child_ids") + + def test_partition_boolean(self): + self.partition_field_test("employee", relational=False) + + def test_partition_dotdot_relational(self): + self.partition_field_test("parent_id.category_id", relational=True, dotdot=True) + + def test_partition_dotdot_nonrelational(self): + self.partition_field_test("parent_id.name", relational=False, dotdot=True) + + def partition_field_test(self, field_name, relational=True, dotdot=False): + """To check that we have a partition we need to check that: + - all field values are keys + - the set of all keys is the same + """ + partition = self.xyz.partition(field_name) + + if relational: + values = [s.mapped(field_name) for s in self.xyz] + else: + values = self.xyz.mapped(field_name) + if dotdot and not relational: + values = [str(s.mapped(field_name)) for s in self.xyz] + self.assertEqual(set(partition.keys()), set(values)) + + records = functools.reduce(sum, partition.values()) + self.assertEqual(self.xyz, records) # we get the same recordset + + def test_partition_lambda(self): + """Test an arbitrary predicate.""" + partition = (self.c1 | self.c2).partition(lambda c: "2" in c.name) + self.assertEqual(set(partition.keys()), {True, False}) + + def test_batch(self): + """The sum of all batches should be the original recordset; + an empty recordset should return no batch; + without a batch parameter, the model's _default_batch_size should be used. + """ + records = self.xyz + batch_size = 2 + + assert len(records) # only makes sense with nonempty recordset + batches = list(records.batch(batch_size)) + self.assertEqual(len(batches), math.ceil(len(records) / batch_size)) + for batch in batches[:-1]: + self.assertEqual(len(batch), batch_size) + last_batch_size = len(records) % batch_size or batch_size + self.assertEqual(len(batches[-1]), last_batch_size) + self.assertEqual(functools.reduce(sum, batches), records) + + empty_recordset = records.browse() + no_batches = list(empty_recordset.batch(batch_size)) + self.assertEqual(no_batches, []) + + with self.assertRaises(UserError): + list(records.batch()) + + records.__class__._default_batch_size = batch_size + batches_from_default = list(records.batch()) + self.assertEqual(batches_from_default, batches) + + def test_read_per_record(self): + categories = self.c1 | self.c2 | self.c3 + field_list = ["name"] + data = categories.read_per_record(field_list) + self.assertEqual(len(data), len(categories)) + for record in categories: + self.assertTrue(record.id in data) + record_data = data[record.id] + self.assertEqual(list(record_data.keys()), field_list) diff --git a/setup/base_partition/odoo/addons/base_partition b/setup/base_partition/odoo/addons/base_partition new file mode 120000 index 000000000..78b8f5a2e --- /dev/null +++ b/setup/base_partition/odoo/addons/base_partition @@ -0,0 +1 @@ +../../../../base_partition \ No newline at end of file diff --git a/setup/base_partition/setup.py b/setup/base_partition/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/base_partition/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)