From 8107893f7aa016e0ee1726f5b1ee3a8c99daed2b Mon Sep 17 00:00:00 2001 From: nans Date: Thu, 3 Sep 2020 09:45:29 +0200 Subject: [PATCH 01/24] [ADD] base_partition: add partition method to base model --- base_partition/README.rst | 84 ++++ base_partition/__init__.py | 1 + base_partition/__manifest__.py | 15 + base_partition/i18n/base_partition.pot | 22 + base_partition/models/__init__.py | 1 + base_partition/models/models.py | 41 ++ base_partition/readme/CONTRIBUTORS.rst | 1 + base_partition/readme/DESCRIPTION.rst | 12 + base_partition/static/description/icon.png | Bin 0 -> 9455 bytes base_partition/static/description/index.html | 428 +++++++++++++++++++ base_partition/tests/__init__.py | 1 + base_partition/tests/test_partition.py | 80 ++++ 12 files changed, 686 insertions(+) create mode 100644 base_partition/README.rst create mode 100644 base_partition/__init__.py create mode 100644 base_partition/__manifest__.py create mode 100644 base_partition/i18n/base_partition.pot create mode 100644 base_partition/models/__init__.py create mode 100644 base_partition/models/models.py create mode 100644 base_partition/readme/CONTRIBUTORS.rst create mode 100644 base_partition/readme/DESCRIPTION.rst create mode 100644 base_partition/static/description/icon.png create mode 100644 base_partition/static/description/index.html create mode 100644 base_partition/tests/__init__.py create mode 100644 base_partition/tests/test_partition.py diff --git a/base_partition/README.rst b/base_partition/README.rst new file mode 100644 index 00000000000..118427deaf0 --- /dev/null +++ b/base_partition/README.rst @@ -0,0 +1,84 @@ +============== +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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/12.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-12-0/server-tools-12-0-base_partition + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/149/12.0 + :alt: Try me on Runbot + +|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)}. + +It also provides a backport of `filtered_domain`, +which filters a recordset in place with a provided domain. + +**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 + +Contributors +~~~~~~~~~~~~ + +* Nans Lefebvre + +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 00000000000..0650744f6bc --- /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 00000000000..fcdebbaa002 --- /dev/null +++ b/base_partition/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2020 Acsone (http://www.acsone.eu) +# Nans Lefebvre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Base Partition", + "summary": "Base module that provide the partition method on all models", + "version": "12.0.0.0.0", + "category": "Uncategorized", + "website": "https://github.com/OCA/server-tools", + "author": "Acsone, Odoo Community Association (OCA)", + "license": "AGPL-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 00000000000..f6210522614 --- /dev/null +++ b/base_partition/i18n/base_partition.pot @@ -0,0 +1,22 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_partition +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-09-04 13:29+0000\n" +"PO-Revision-Date: 2020-09-04 13:29+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 "" + diff --git a/base_partition/models/__init__.py b/base_partition/models/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /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 00000000000..ed818ac241b --- /dev/null +++ b/base_partition/models/models.py @@ -0,0 +1,41 @@ +# © 2020 Acsone (http://www.acsone.eu) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models + +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 diff --git a/base_partition/readme/CONTRIBUTORS.rst b/base_partition/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..4e30b28db7a --- /dev/null +++ b/base_partition/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Nans Lefebvre diff --git a/base_partition/readme/DESCRIPTION.rst b/base_partition/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..7b96e6257a1 --- /dev/null +++ b/base_partition/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +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)}. + +It also provides a backport of `filtered_domain`, +which filters a recordset in place with a provided domain. diff --git a/base_partition/static/description/icon.png b/base_partition/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/base_partition/static/description/index.html b/base_partition/static/description/index.html new file mode 100644 index 00000000000..9b0627c6879 --- /dev/null +++ b/base_partition/static/description/index.html @@ -0,0 +1,428 @@ + + + + + + +Base Partition + + + +
+

Base Partition

+ + +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runbot

+

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)}.

+

It also provides a backport of filtered_domain, +which filters a recordset in place with a provided domain.

+

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
  • +
+
+
+

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 00000000000..7b173054be4 --- /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 00000000000..942521db760 --- /dev/null +++ b/base_partition/tests/test_partition.py @@ -0,0 +1,80 @@ +# Copyright 2017 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import functools +from odoo.tests.common import TransactionCase + + +class TestPartition(TransactionCase): + def setUp(self): + super(TestPartition, self).setUp() + + self.Category = self.env["res.partner.category"] + self.c1 = self.Category.create({"name": "c1"}) + self.c2 = self.Category.create({"name": "c2"}) + self.c3 = self.Category.create({"name": "c3"}) + + self.Partner = self.env['res.partner'] + self.parent1 = self.Partner.create({"name": "parent1"}) + self.parent2 = self.Partner.create({"name": "parent2"}) + self.child1 = self.Partner.create({"name": "child1"}) + self.child2 = self.Partner.create({"name": "child2"}) + self.child3 = self.Partner.create({"name": "child3"}) + self.x = self.Partner.create({ + "name": "x", + "customer": True, + "category_id": [(6, 0, [self.c1.id, self.c2.id])], + "child_ids": [(6, 0, [self.child1.id, self.child2.id])], + "parent_id": self.parent1.id, + }) + self.y = self.Partner.create({ + "name": "y", + "customer": False, + "category_id": [(6, 0, [self.c2.id, self.c3.id])], + "child_ids": [(6, 0, [self.child2.id, self.child3.id])], + "parent_id": self.parent2.id, + }) + self.z = self.Partner.create({ + "name": "z", + "customer": False, + "category_id": [(6, 0, [self.c1.id, self.c3.id])], + "child_ids": [(6, 0, [self.child1.id, self.child3.id])], + "parent_id": self.parent2.id, + }) + self.xyz = self.x + self.y + self.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("customer", 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 From cce4e093b706f6bb68182dcb2bd4dbb492fe5560 Mon Sep 17 00:00:00 2001 From: nans Date: Mon, 7 Sep 2020 17:32:28 +0200 Subject: [PATCH 02/24] [IMP] models: backport filtered_domain --- base_partition/models/models.py | 120 ++++++++++++++++++++++++- base_partition/tests/test_partition.py | 108 +++++++++++++++++----- 2 files changed, 205 insertions(+), 23 deletions(-) diff --git a/base_partition/models/models.py b/base_partition/models/models.py index ed818ac241b..d96613cb3be 100644 --- a/base_partition/models/models.py +++ b/base_partition/models/models.py @@ -1,7 +1,19 @@ # © 2020 Acsone (http://www.acsone.eu) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import models +import fnmatch +from odoo import fields, models +from odoo.osv import expression + +LIKE_COMPARATORS = ( + 'like', + 'ilike', + '=like', + '=ilike', + 'not ilike', + 'not like', +) + class Base(models.AbstractModel): @@ -39,3 +51,109 @@ def partition(self, accessor): partition[key] += record return partition + + def filtered_domain(self, domain): + """Backport from standard. + """ + if not domain: + return self + result = [] + for d in reversed(domain): + if d == '|': + result.append(result.pop() | result.pop()) + elif d == '!': + result.append(self - result.pop()) + elif d == '&': + result.append(result.pop() & result.pop()) + elif d == expression.TRUE_LEAF: + result.append(self) + elif d == expression.FALSE_LEAF: + result.append(self.browse()) + else: + (key, comparator, value) = d + if key.endswith('.id'): + key = key[:-3] + if key == 'id': + key = '' + # determine the field with the final type for values + field = None + if key: + model = self.browse() + for fname in key.split('.'): + field = model._fields[fname] + model = model[fname] + + if comparator in LIKE_COMPARATORS: + value_esc = ( + value.replace('_', '?') + .replace('%', '*') + .replace('[', '?') + ) + records = self.browse() + for rec in self: + data = rec.mapped(key) + if comparator in ('child_of', 'parent_of'): + records = data.search([(data._parent_name, comparator, value)]) + value = records.ids + comparator = 'in' + if isinstance(data, models.BaseModel): + v = value + if isinstance(value, (list, tuple)) and len(value): + v = value[0] + if isinstance(v, str): + data = data.mapped('display_name') + else: + data = data.ids if data else [False] + elif field and field.type in ('date', 'datetime'): + # convert all date and datetime values to datetime + normalize = fields.Datetime.to_datetime + if isinstance(value, (list, tuple)): + value = [normalize(v) for v in value] + else: + value = normalize(value) + data = [normalize(d) for d in data] + if comparator in ('in', 'not in'): + if not (isinstance(value, list) or isinstance(value, tuple)): + value = [value] + + if comparator == '=': + ok = value in data + elif comparator == 'in': + ok = any(map(lambda x: x in data, value)) + elif comparator == '<': + ok = any(map(lambda x: x is not None and x < value, data)) + elif comparator == '>': + ok = any(map(lambda x: x is not None and x > value, data)) + elif comparator == '<=': + ok = any(map(lambda x: x is not None and x <= value, data)) + elif comparator == '>=': + ok = any(map(lambda x: x is not None and x >= value, data)) + elif comparator in ('!=', '<>'): + ok = value not in data + elif comparator == 'not in': + ok = all(map(lambda x: x not in data, value)) + elif comparator == 'not ilike': + ok = all(map(lambda x: value.lower() not in x.lower(), data)) + elif comparator == 'ilike': + data = [x.lower() for x in data] + match = fnmatch.filter(data, '*'+(value_esc or '').lower()+'*') + ok = bool(match) + elif comparator == 'not like': + ok = all(map(lambda x: value not in x, data)) + elif comparator == 'like': + ok = bool(fnmatch.filter(data, value and '*'+value_esc+'*')) + elif comparator == '=?': + ok = (value in data) or not value + elif comparator == '=like': + ok = bool(fnmatch.filter(data, value_esc)) + elif comparator == '=ilike': + data = [x.lower() for x in data] + ok = bool(fnmatch.filter(data, value and value_esc.lower())) + else: + raise ValueError + if ok: + records |= rec + result.append(records) + while len(result) > 1: + result.append(result.pop() & result.pop()) + return result[0] diff --git a/base_partition/tests/test_partition.py b/base_partition/tests/test_partition.py index 942521db760..f8146d6fb2c 100644 --- a/base_partition/tests/test_partition.py +++ b/base_partition/tests/test_partition.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import functools + from odoo.tests.common import TransactionCase @@ -14,33 +15,39 @@ def setUp(self): self.c2 = self.Category.create({"name": "c2"}) self.c3 = self.Category.create({"name": "c3"}) - self.Partner = self.env['res.partner'] + self.Partner = self.env["res.partner"] self.parent1 = self.Partner.create({"name": "parent1"}) self.parent2 = self.Partner.create({"name": "parent2"}) self.child1 = self.Partner.create({"name": "child1"}) self.child2 = self.Partner.create({"name": "child2"}) self.child3 = self.Partner.create({"name": "child3"}) - self.x = self.Partner.create({ - "name": "x", - "customer": True, - "category_id": [(6, 0, [self.c1.id, self.c2.id])], - "child_ids": [(6, 0, [self.child1.id, self.child2.id])], - "parent_id": self.parent1.id, - }) - self.y = self.Partner.create({ - "name": "y", - "customer": False, - "category_id": [(6, 0, [self.c2.id, self.c3.id])], - "child_ids": [(6, 0, [self.child2.id, self.child3.id])], - "parent_id": self.parent2.id, - }) - self.z = self.Partner.create({ - "name": "z", - "customer": False, - "category_id": [(6, 0, [self.c1.id, self.c3.id])], - "child_ids": [(6, 0, [self.child1.id, self.child3.id])], - "parent_id": self.parent2.id, - }) + self.x = self.Partner.create( + { + "name": "x", + "customer": True, + "category_id": [(6, 0, [self.c1.id, self.c2.id])], + "child_ids": [(6, 0, [self.child1.id, self.child2.id])], + "parent_id": self.parent1.id, + } + ) + self.y = self.Partner.create( + { + "name": "y", + "customer": False, + "category_id": [(6, 0, [self.c2.id, self.c3.id])], + "child_ids": [(6, 0, [self.child2.id, self.child3.id])], + "parent_id": self.parent2.id, + } + ) + self.z = self.Partner.create( + { + "name": "z", + "customer": False, + "category_id": [(6, 0, [self.c1.id, self.c3.id])], + "child_ids": [(6, 0, [self.child1.id, self.child3.id])], + "parent_id": self.parent2.id, + } + ) self.xyz = self.x + self.y + self.z def test_partition_many2many(self): @@ -78,3 +85,60 @@ def partition_field_test(self, field_name, relational=True, dotdot=False): records = functools.reduce(sum, partition.values()) self.assertEqual(self.xyz, records) # we get the same recordset + + def test_filtered_domain(self): + """Initially yo satisfy the coverage tools, this test actually documents + a number of pitfalls of filtered_domain and the differences with a search. + Commented examples would cause warnings, and even though these are edge-cases + these behaviours should be known. + """ + + records = self.xyz + empty_recordset = records.browse() + + def filtered_search(domain): + search = self.xyz.search(domain) + return search.filtered(lambda r: r.id in self.xyz.ids) + + self.assertEqual(records, records.filtered_domain([])) + self.assertEqual(empty_recordset, records.filtered_domain([(0, "=", 1)])) + + for field in ["name"]: + for r in self.xyz: + domain = [(field, "=", r[field])] + self.assertEqual(self.xyz.filtered_domain(domain), r) + self.assertEqual(filtered_search(domain), r) + + domain = [(field, "in", r[field])] + self.assertTrue(self.xyz.filtered_domain(domain), r) + with self.assertRaises(ValueError): + filtered_search(domain) + + for field in ["customer"]: + for r in [self.x, self.y | self.z]: + value = r[0][field] + domain = [(field, "=", value)] + self.assertEqual(self.xyz.filtered_domain(domain), r) + self.assertEqual(filtered_search(domain), r) + # domain = [(field, "in", value)] + # self.assertEqual(self.xyz.filtered_domain(domain), r) + # expected_result = r if value else empty_recordset # ! + # self.assertEqual(filtered_search(domain), expected_result) + + for field in ["parent_id"]: + for r in [self.x, self.y | self.z]: + domain = [(field, "=", r[0][field].id)] + self.assertEqual(self.xyz.filtered_domain(domain), r) + self.assertEqual(filtered_search(domain), r) + domain = [(field, "in", r[0][field].ids)] + self.assertEqual(self.xyz.filtered_domain(domain), r) + self.assertEqual(filtered_search(domain), r) + + for r in self.xyz: + field = "category_id" + in_domain = [(field, "in", r[field].ids)] + self.assertEqual(self.xyz.filtered_domain(in_domain), self.xyz) + self.assertEqual(self.xyz.search(in_domain), self.xyz) + # eq_domain = [(field, "=", r[field].ids)] + # self.assertEqual(self.xyz.search(eq_domain), self.xyz) + # self.assertEqual(self.xyz.filtered_domain(eq_domain), empty_recordset) From a4315a4401fd7c0bfcfbef21fddb7c6d4b2caf2b Mon Sep 17 00:00:00 2001 From: nans Date: Tue, 8 Sep 2020 20:17:41 +0200 Subject: [PATCH 03/24] [IMP] base_partition: add batch method on base --- base_partition/models/models.py | 95 +++++++++++++++----------- base_partition/tests/test_partition.py | 30 ++++++++ 2 files changed, 85 insertions(+), 40 deletions(-) diff --git a/base_partition/models/models.py b/base_partition/models/models.py index d96613cb3be..8100969cded 100644 --- a/base_partition/models/models.py +++ b/base_partition/models/models.py @@ -2,22 +2,24 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import fnmatch -from odoo import fields, models + +from odoo import _, fields, models +from odoo.exceptions import UserError from odoo.osv import expression LIKE_COMPARATORS = ( - 'like', - 'ilike', - '=like', - '=ilike', - 'not ilike', - 'not like', + "like", + "ilike", + "=like", + "=ilike", + "not ilike", + "not like", ) class Base(models.AbstractModel): - _inherit = 'base' + _inherit = "base" def partition(self, accessor): """Returns a dictionary forming a partition of self into a dictionary @@ -52,6 +54,19 @@ def partition(self, accessor): 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 filtered_domain(self, domain): """Backport from standard. """ @@ -59,11 +74,11 @@ def filtered_domain(self, domain): return self result = [] for d in reversed(domain): - if d == '|': + if d == "|": result.append(result.pop() | result.pop()) - elif d == '!': + elif d == "!": result.append(self - result.pop()) - elif d == '&': + elif d == "&": result.append(result.pop() & result.pop()) elif d == expression.TRUE_LEAF: result.append(self) @@ -71,40 +86,38 @@ def filtered_domain(self, domain): result.append(self.browse()) else: (key, comparator, value) = d - if key.endswith('.id'): + if key.endswith(".id"): key = key[:-3] - if key == 'id': - key = '' + if key == "id": + key = "" # determine the field with the final type for values field = None if key: model = self.browse() - for fname in key.split('.'): + for fname in key.split("."): field = model._fields[fname] model = model[fname] if comparator in LIKE_COMPARATORS: value_esc = ( - value.replace('_', '?') - .replace('%', '*') - .replace('[', '?') + value.replace("_", "?").replace("%", "*").replace("[", "?") ) records = self.browse() for rec in self: data = rec.mapped(key) - if comparator in ('child_of', 'parent_of'): + if comparator in ("child_of", "parent_of"): records = data.search([(data._parent_name, comparator, value)]) value = records.ids - comparator = 'in' + comparator = "in" if isinstance(data, models.BaseModel): v = value if isinstance(value, (list, tuple)) and len(value): v = value[0] if isinstance(v, str): - data = data.mapped('display_name') + data = data.mapped("display_name") else: data = data.ids if data else [False] - elif field and field.type in ('date', 'datetime'): + elif field and field.type in ("date", "datetime"): # convert all date and datetime values to datetime normalize = fields.Datetime.to_datetime if isinstance(value, (list, tuple)): @@ -112,41 +125,43 @@ def filtered_domain(self, domain): else: value = normalize(value) data = [normalize(d) for d in data] - if comparator in ('in', 'not in'): + if comparator in ("in", "not in"): if not (isinstance(value, list) or isinstance(value, tuple)): value = [value] - if comparator == '=': + if comparator == "=": ok = value in data - elif comparator == 'in': + elif comparator == "in": ok = any(map(lambda x: x in data, value)) - elif comparator == '<': + elif comparator == "<": ok = any(map(lambda x: x is not None and x < value, data)) - elif comparator == '>': + elif comparator == ">": ok = any(map(lambda x: x is not None and x > value, data)) - elif comparator == '<=': + elif comparator == "<=": ok = any(map(lambda x: x is not None and x <= value, data)) - elif comparator == '>=': + elif comparator == ">=": ok = any(map(lambda x: x is not None and x >= value, data)) - elif comparator in ('!=', '<>'): + elif comparator in ("!=", "<>"): ok = value not in data - elif comparator == 'not in': + elif comparator == "not in": ok = all(map(lambda x: x not in data, value)) - elif comparator == 'not ilike': + elif comparator == "not ilike": ok = all(map(lambda x: value.lower() not in x.lower(), data)) - elif comparator == 'ilike': + elif comparator == "ilike": data = [x.lower() for x in data] - match = fnmatch.filter(data, '*'+(value_esc or '').lower()+'*') + match = fnmatch.filter( + data, "*" + (value_esc or "").lower() + "*" + ) ok = bool(match) - elif comparator == 'not like': + elif comparator == "not like": ok = all(map(lambda x: value not in x, data)) - elif comparator == 'like': - ok = bool(fnmatch.filter(data, value and '*'+value_esc+'*')) - elif comparator == '=?': + elif comparator == "like": + ok = bool(fnmatch.filter(data, value and "*" + value_esc + "*")) + elif comparator == "=?": ok = (value in data) or not value - elif comparator == '=like': + elif comparator == "=like": ok = bool(fnmatch.filter(data, value_esc)) - elif comparator == '=ilike': + elif comparator == "=ilike": data = [x.lower() for x in data] ok = bool(fnmatch.filter(data, value and value_esc.lower())) else: diff --git a/base_partition/tests/test_partition.py b/base_partition/tests/test_partition.py index f8146d6fb2c..352ac1824dd 100644 --- a/base_partition/tests/test_partition.py +++ b/base_partition/tests/test_partition.py @@ -2,7 +2,9 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import functools +import math +from odoo.exceptions import UserError from odoo.tests.common import TransactionCase @@ -86,6 +88,34 @@ def partition_field_test(self, field_name, relational=True, dotdot=False): records = functools.reduce(sum, partition.values()) self.assertEqual(self.xyz, records) # we get the same recordset + 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_filtered_domain(self): """Initially yo satisfy the coverage tools, this test actually documents a number of pitfalls of filtered_domain and the differences with a search. From 858643e887b4c69d62145b1a407e7225c8b35f7b Mon Sep 17 00:00:00 2001 From: nans Date: Sun, 11 Oct 2020 20:13:49 +0200 Subject: [PATCH 04/24] [IMP] base_partition: add read_per_record method --- base_partition/models/models.py | 8 ++++++++ base_partition/tests/test_partition.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/base_partition/models/models.py b/base_partition/models/models.py index 8100969cded..8e73f1d1c1f 100644 --- a/base_partition/models/models.py +++ b/base_partition/models/models.py @@ -67,6 +67,14 @@ def batch(self, batch_size=None): 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 + def filtered_domain(self, domain): """Backport from standard. """ diff --git a/base_partition/tests/test_partition.py b/base_partition/tests/test_partition.py index 352ac1824dd..031ed825c8d 100644 --- a/base_partition/tests/test_partition.py +++ b/base_partition/tests/test_partition.py @@ -116,6 +116,16 @@ def test_batch(self): 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) + def test_filtered_domain(self): """Initially yo satisfy the coverage tools, this test actually documents a number of pitfalls of filtered_domain and the differences with a search. From df1e578347c4a1cbfc26d5422ab3740d9c21ab35 Mon Sep 17 00:00:00 2001 From: nans Date: Sun, 11 Oct 2020 21:17:43 +0200 Subject: [PATCH 05/24] [TEST] base_partition: add coverage --- base_partition/__manifest__.py | 2 +- base_partition/tests/test_partition.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/base_partition/__manifest__.py b/base_partition/__manifest__.py index fcdebbaa002..a68fdf91b7a 100644 --- a/base_partition/__manifest__.py +++ b/base_partition/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Base Partition", "summary": "Base module that provide the partition method on all models", - "version": "12.0.0.0.0", + "version": "12.0.1.0.0", "category": "Uncategorized", "website": "https://github.com/OCA/server-tools", "author": "Acsone, Odoo Community Association (OCA)", diff --git a/base_partition/tests/test_partition.py b/base_partition/tests/test_partition.py index 031ed825c8d..1fcb720c8c1 100644 --- a/base_partition/tests/test_partition.py +++ b/base_partition/tests/test_partition.py @@ -3,7 +3,9 @@ import functools import math +from datetime import datetime +from odoo import fields from odoo.exceptions import UserError from odoo.tests.common import TransactionCase @@ -88,6 +90,11 @@ def partition_field_test(self, field_name, relational=True, dotdot=False): 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; @@ -127,7 +134,7 @@ def test_read_per_record(self): self.assertEqual(list(record_data.keys()), field_list) def test_filtered_domain(self): - """Initially yo satisfy the coverage tools, this test actually documents + """Initially to satisfy the coverage tools, this test actually documents a number of pitfalls of filtered_domain and the differences with a search. Commented examples would cause warnings, and even though these are edge-cases these behaviours should be known. @@ -182,3 +189,17 @@ def filtered_search(domain): # eq_domain = [(field, "=", r[field].ids)] # self.assertEqual(self.xyz.search(eq_domain), self.xyz) # self.assertEqual(self.xyz.filtered_domain(eq_domain), empty_recordset) + + # coverage + records.filtered_domain(["!", (1, "=", 1)]) + for operator in ["<", ">", "<=", ">="]: + date_now_str = fields.Datetime.to_string(datetime.now()) + records.filtered_domain([("create_date", operator, date_now_str)]) + for operator in ["!=", "like", "not like", "=?", "ilike", "=like", "not ilike"]: + records.filtered_domain([("name", operator, "c")]) + records.filtered_domain( + ["&", ("parent_id.id", "in", [self.parent1.id]), (1, "=", 1)] + ) + records.filtered_domain(["|", ("parent_id", "child_of", [42]), (0, "=", 1)]) + with self.assertRaises(ValueError): + records.filtered_domain([("name", "===", "test")]) From 1303cdac38169e860ab6bbdac4f267322e83563c Mon Sep 17 00:00:00 2001 From: hda Date: Mon, 24 Apr 2023 17:00:45 +0200 Subject: [PATCH 06/24] [16.0][MIG] base_partition --- base_partition/README.rst | 26 ++-- base_partition/__manifest__.py | 8 +- base_partition/i18n/base_partition.pot | 18 ++- base_partition/i18n/fr_BE.pot | 32 ++++ base_partition/models/models.py | 140 ++---------------- base_partition/readme/CONTRIBUTORS.rst | 1 + base_partition/readme/DESCRIPTION.rst | 3 - base_partition/static/description/index.html | 13 +- base_partition/tests/test_partition.py | 147 +++++-------------- 9 files changed, 117 insertions(+), 271 deletions(-) create mode 100644 base_partition/i18n/fr_BE.pot diff --git a/base_partition/README.rst b/base_partition/README.rst index 118427deaf0..d66f63daa15 100644 --- a/base_partition/README.rst +++ b/base_partition/README.rst @@ -10,18 +10,18 @@ Base Partition .. |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-AGPL--3-blue.png - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 +.. |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/12.0/base_partition + :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-12-0/server-tools-12-0-base_partition + :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/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/149/12.0 - :alt: Try me on Runbot +.. |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| @@ -35,9 +35,6 @@ and with values that are recordsets 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)}. -It also provides a backport of `filtered_domain`, -which filters a recordset in place with a provided domain. - **Table of contents** .. contents:: @@ -49,7 +46,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -59,12 +56,13 @@ Credits Authors ~~~~~~~ -* Acsone +* Acsone SA/NV Contributors ~~~~~~~~~~~~ * Nans Lefebvre +* Hughes Damry Maintainers ~~~~~~~~~~~ @@ -79,6 +77,6 @@ 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. +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/__manifest__.py b/base_partition/__manifest__.py index a68fdf91b7a..9fa23c69567 100644 --- a/base_partition/__manifest__.py +++ b/base_partition/__manifest__.py @@ -1,15 +1,15 @@ # Copyright 2020 Acsone (http://www.acsone.eu) # Nans Lefebvre -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# 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": "12.0.1.0.0", + "version": "16.0.1.0.0", "category": "Uncategorized", "website": "https://github.com/OCA/server-tools", - "author": "Acsone, Odoo Community Association (OCA)", - "license": "AGPL-3", + "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 index f6210522614..ab5717e74b4 100644 --- a/base_partition/i18n/base_partition.pot +++ b/base_partition/i18n/base_partition.pot @@ -1,14 +1,14 @@ # Translation of Odoo Server. # This file contains the translation of the following modules: -# * base_partition +# * base_partition # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 12.0\n" +"Project-Id-Version: Odoo Server 16.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-04 13:29+0000\n" -"PO-Revision-Date: 2020-09-04 13:29+0000\n" -"Last-Translator: <>\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" @@ -20,3 +20,11 @@ msgstr "" 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 00000000000..4befdf2a7bb --- /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/models.py b/base_partition/models/models.py index 8e73f1d1c1f..4adf275cec4 100644 --- a/base_partition/models/models.py +++ b/base_partition/models/models.py @@ -1,20 +1,8 @@ # © 2020 Acsone (http://www.acsone.eu) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -import fnmatch - -from odoo import _, fields, models +from odoo import _, models from odoo.exceptions import UserError -from odoo.osv import expression - -LIKE_COMPARATORS = ( - "like", - "ilike", - "=like", - "=ilike", - "not ilike", - "not like", -) class Base(models.AbstractModel): @@ -23,15 +11,15 @@ class Base(models.AbstractModel): 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. + 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 = {} @@ -74,109 +62,3 @@ def read_per_record(self, fields=None, load="_classic_read"): key = d.pop("id") result[key] = d return result - - def filtered_domain(self, domain): - """Backport from standard. - """ - if not domain: - return self - result = [] - for d in reversed(domain): - if d == "|": - result.append(result.pop() | result.pop()) - elif d == "!": - result.append(self - result.pop()) - elif d == "&": - result.append(result.pop() & result.pop()) - elif d == expression.TRUE_LEAF: - result.append(self) - elif d == expression.FALSE_LEAF: - result.append(self.browse()) - else: - (key, comparator, value) = d - if key.endswith(".id"): - key = key[:-3] - if key == "id": - key = "" - # determine the field with the final type for values - field = None - if key: - model = self.browse() - for fname in key.split("."): - field = model._fields[fname] - model = model[fname] - - if comparator in LIKE_COMPARATORS: - value_esc = ( - value.replace("_", "?").replace("%", "*").replace("[", "?") - ) - records = self.browse() - for rec in self: - data = rec.mapped(key) - if comparator in ("child_of", "parent_of"): - records = data.search([(data._parent_name, comparator, value)]) - value = records.ids - comparator = "in" - if isinstance(data, models.BaseModel): - v = value - if isinstance(value, (list, tuple)) and len(value): - v = value[0] - if isinstance(v, str): - data = data.mapped("display_name") - else: - data = data.ids if data else [False] - elif field and field.type in ("date", "datetime"): - # convert all date and datetime values to datetime - normalize = fields.Datetime.to_datetime - if isinstance(value, (list, tuple)): - value = [normalize(v) for v in value] - else: - value = normalize(value) - data = [normalize(d) for d in data] - if comparator in ("in", "not in"): - if not (isinstance(value, list) or isinstance(value, tuple)): - value = [value] - - if comparator == "=": - ok = value in data - elif comparator == "in": - ok = any(map(lambda x: x in data, value)) - elif comparator == "<": - ok = any(map(lambda x: x is not None and x < value, data)) - elif comparator == ">": - ok = any(map(lambda x: x is not None and x > value, data)) - elif comparator == "<=": - ok = any(map(lambda x: x is not None and x <= value, data)) - elif comparator == ">=": - ok = any(map(lambda x: x is not None and x >= value, data)) - elif comparator in ("!=", "<>"): - ok = value not in data - elif comparator == "not in": - ok = all(map(lambda x: x not in data, value)) - elif comparator == "not ilike": - ok = all(map(lambda x: value.lower() not in x.lower(), data)) - elif comparator == "ilike": - data = [x.lower() for x in data] - match = fnmatch.filter( - data, "*" + (value_esc or "").lower() + "*" - ) - ok = bool(match) - elif comparator == "not like": - ok = all(map(lambda x: value not in x, data)) - elif comparator == "like": - ok = bool(fnmatch.filter(data, value and "*" + value_esc + "*")) - elif comparator == "=?": - ok = (value in data) or not value - elif comparator == "=like": - ok = bool(fnmatch.filter(data, value_esc)) - elif comparator == "=ilike": - data = [x.lower() for x in data] - ok = bool(fnmatch.filter(data, value and value_esc.lower())) - else: - raise ValueError - if ok: - records |= rec - result.append(records) - while len(result) > 1: - result.append(result.pop() & result.pop()) - return result[0] diff --git a/base_partition/readme/CONTRIBUTORS.rst b/base_partition/readme/CONTRIBUTORS.rst index 4e30b28db7a..e2702f0485c 100644 --- a/base_partition/readme/CONTRIBUTORS.rst +++ b/base_partition/readme/CONTRIBUTORS.rst @@ -1 +1,2 @@ * Nans Lefebvre +* Hughes Damry diff --git a/base_partition/readme/DESCRIPTION.rst b/base_partition/readme/DESCRIPTION.rst index 7b96e6257a1..bd33c61d613 100644 --- a/base_partition/readme/DESCRIPTION.rst +++ b/base_partition/readme/DESCRIPTION.rst @@ -7,6 +7,3 @@ and with values that are recordsets 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)}. - -It also provides a backport of `filtered_domain`, -which filters a recordset in place with a provided domain. diff --git a/base_partition/static/description/index.html b/base_partition/static/description/index.html index 9b0627c6879..c65a625bfef 100644 --- a/base_partition/static/description/index.html +++ b/base_partition/static/description/index.html @@ -3,7 +3,7 @@ - + Base Partition -
-

Base Partition

+
+ + +Odoo Community Association + +
+

Base Partition

-

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

+

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 @@ -392,30 +397,30 @@

Base Partition

-

Bug Tracker

+

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 to smash it by providing a detailed and welcomed -feedback.

+feedback.

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Acsone SA/NV
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -423,10 +428,11 @@

Maintainers

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.

+

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.

+