diff --git a/.oca/oca-port/blacklist/connector_importer_product.json b/.oca/oca-port/blacklist/connector_importer_product.json new file mode 100644 index 000000000..aa6eeea3d --- /dev/null +++ b/.oca/oca-port/blacklist/connector_importer_product.json @@ -0,0 +1,8 @@ +{ + "pull_requests": { + "OCA/connector-interfaces#110": "nothing to port", + "OCA/connector-interfaces#116": "nothing to port", + "OCA/connector-interfaces#118": "nothing to port", + "OCA/connector-interfaces#119": "nothing to port" + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f046743f0..b2ea2a13b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS - ^connector_importer_product/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| diff --git a/connector_importer_product/__manifest__.py b/connector_importer_product/__manifest__.py index 289491bef..688b4f61a 100644 --- a/connector_importer_product/__manifest__.py +++ b/connector_importer_product/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Connector Importer Product", "summary": "Ease definition of product imports using `connector_importer`.", - "version": "15.0.1.1.0", + "version": "16.0.1.0.0", "category": "Tools", "website": "https://github.com/OCA/connector-interfaces", "author": "Camptocamp, Odoo Community Association (OCA)", @@ -22,5 +22,4 @@ "demo/import_source.xml", "demo/import_recordset.xml", ], - "installable": False, } diff --git a/connector_importer_product/components/product_product/record_handler.py b/connector_importer_product/components/product_product/record_handler.py index 8b78c4453..b902d45b5 100644 --- a/connector_importer_product/components/product_product/record_handler.py +++ b/connector_importer_product/components/product_product/record_handler.py @@ -33,6 +33,34 @@ def odoo_post_create(self, odoo_record, values, orig_values): def odoo_post_write(self, odoo_record, values, orig_values): self._update_template_attributes(odoo_record, values, orig_values) + def odoo_create(self, values, orig_values): + odoo_record = super().odoo_create(values, orig_values) + # Set the external ID for the template if necessary + # TODO: add tests + self._handle_template_xid(odoo_record, values, orig_values) + return odoo_record + + def _handle_template_xid(self, odoo_record, values, orig_values): + """Create the xid for the template if needed. + + The xid for the variant has been already created by `odoo_create`. + If the template is identified via xid using the column `xid::product_tmpl_id` + we must create this reference or other variant lines won't use the same template. + """ + if self.must_generate_xmlid and orig_values.get("xid::product_tmpl_id"): + tmpl_xid = sanitize_external_id(orig_values.get("xid::product_tmpl_id")) + if not self.env.ref(tmpl_xid, raise_if_not_found=False): + module, id_ = tmpl_xid.split(".", 1) + self.env["ir.model.data"].create( + { + "name": id_, + "module": module, + "model": odoo_record.product_tmpl_id._name, + "res_id": odoo_record.product_tmpl_id.id, + "noupdate": False, + } + ) + def _update_template_attributes(self, odoo_record, values, orig_values): """Update the 'attribute_line_ids' field of the related template. @@ -59,13 +87,15 @@ def _update_template_attributes(self, odoo_record, values, orig_values): * product attribute column values will be used to find the values which were already imported first by name then by XID. - See `_find_attr_value` docs. + See `_find_or_create_attr_value` docs. """ TplAttrLine = self.env["product.template.attribute.line"] TplAttrValue = self.env["product.template.attribute.value"] template = odoo_record.product_tmpl_id + blacklist = self.work.options.mapper.get("source_key_blacklist", []) attr_columns = filter( - lambda col: col.startswith("product_attr"), orig_values.keys() + lambda col: col.startswith("product_attr") and col not in blacklist, + orig_values.keys(), ) tpl_attr_values = self.env["product.template.attribute.value"] # Detect and gather attributes and attribute values to import @@ -73,7 +103,8 @@ def _update_template_attributes(self, odoo_record, values, orig_values): for attr_column in attr_columns: if not orig_values[attr_column]: continue - attr_value = self._find_attr_value(orig_values, attr_column) + attr = self._find_attr(attr_column, orig_values) + attr_value = self._find_or_create_attr_value(attr, attr_column, orig_values) if attr_value: attr_values_to_import_ids.append(attr_value.id) # Detect if the set of attributes among this template is wrong @@ -163,28 +194,48 @@ def _update_template_attributes(self, odoo_record, values, orig_values): else: odoo_record.product_template_attribute_value_ids = valid_tpl_attr_values + def _find_attr(self, attr_column, orig_values): + """Find matching attribute.""" + attr_xid = sanitize_external_id(attr_column) + return self.env.ref(attr_xid) + # TODO: add unit test - def _find_attr_value(self, orig_values, attr_column): + def _find_or_create_attr_value(self, attr, attr_column, orig_values): """Find matching attribute value. - FIXME + The column name is used to compute the product.attribute xid. Eg: + + * product_attr_Size -> __setup__.product_attr_Size + * product_attr_Color -> __setup__.product_attr_Color - By computing their XID w/ the column name - + _value_ + the value itself. + The value will be used to determine the product.attribute.value. + The lookup happens in this order: - For instance, a column `product_attr_Size` could have the values - "S" , "M", "L" and they will be converted - to find their matching attributes, like this: + 1. search by name + 2. search by xid, assuming the value itself is already an xid. + 3. search by composed xid, assuming the value is the last part of an xid. + The first part is computed as: `__setup__.$product_attr_xid_value_$col_value`. + For instance, a column `product_attr_Size` could have the values + "S" , "M", "L" and they will be converted + to find their matching attributes, like this: - * S -> product_attr_Size_value_S - * M -> product_attr_Size_value_M - * L -> product_attr_Size_value_L + * S -> product_attr_Size_value_S + * M -> product_attr_Size_value_M + * L -> product_attr_Size_value_L - If no attribute value matching this convention is found, - the value will be skipped. + If no attribute value matching this convention is found, + the value will be skipped unless `create_attribute_value_if_missing` + flag is passed to `record_handler` options. Eg: + + - model: product.product + options: + importer: + odoo_unique_key: barcode + mapper: + name: product.product.mapper + record_handler: + create_attribute_value_if_missing: true """ - attr_xid = sanitize_external_id(attr_column) - attr = self.env.ref(attr_xid) # 1st search by name orig_val = orig_values[attr_column] model = self.env["product.attribute.value"] @@ -196,10 +247,44 @@ def _find_attr_value(self, orig_values, attr_column): attr_value = self.env.ref(sanitize_external_id(orig_val), False) if not attr_value and "_value_" not in orig_val: # 3rd try w/ auto generated xid - value = slugify_one(orig_val).replace("-", "_") - xid = f"{attr_column}_value_{value}" - attr_value_external_id = sanitize_external_id(xid) - attr_value = self.env.ref(attr_value_external_id, False) + attr_value_xid = self._make_attribute_value_xid(attr_column, orig_val) + attr_value = self.env.ref(attr_value_xid, False) + if not attr_value and self.create_attribute_value_if_missing: + attr_value = self._create_missing_attribute_value( + attr, attr_column, orig_val + ) if not attr_value: logger.error("Cannot determine product attr value: %s", orig_val) return attr_value + + def _make_attribute_value_xid(self, attr_column, orig_val): + value = slugify_one(orig_val).replace("-", "_") + xid = f"{attr_column}_value_{value}" + return sanitize_external_id(xid) + + def _create_missing_attribute_value(self, attr, attr_column, orig_val): + rec = self.env["product.attribute.value"].create( + self._create_missing_attribute_value_values(attr, orig_val) + ) + xid = self._make_attribute_value_xid(attr_column, orig_val) + module, id_ = xid.split(".", 1) + self.env["ir.model.data"].create( + { + "name": id_, + "module": module, + "model": rec._name, + "res_id": rec.id, + "noupdate": False, + } + ) + logger.info("Created product.attribute.value: %s", xid) + return rec + + def _create_missing_attribute_value_values(self, attr, orig_val): + return {"attribute_id": attr.id, "name": orig_val} + + @property + def create_attribute_value_if_missing(self): + return self.work.options.record_handler.get( + "create_attribute_value_if_missing", False + ) diff --git a/connector_importer_product/components/product_supplierinfo.py b/connector_importer_product/components/product_supplierinfo.py index 373e6804c..b094acd46 100644 --- a/connector_importer_product/components/product_supplierinfo.py +++ b/connector_importer_product/components/product_supplierinfo.py @@ -2,7 +2,9 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping from odoo.addons.connector_importer.utils.mapper_utils import backend_to_rel +from odoo.addons.connector_importer.utils.misc import sanitize_external_id class ProductSupplierinfoMapper(Component): @@ -12,22 +14,31 @@ class ProductSupplierinfoMapper(Component): _apply_on = "product.supplierinfo" _usage = "importer.mapper" - direct = [ - ( - backend_to_rel( - "tmpl_default_code", - # See motiviation in product_product.mapper. - search_field="product_variant_ids.default_code", - ), - "product_tmpl_id", - ), - ] required = { - "__name": "name", + "__name": "partner_id", "__tmpl": "product_tmpl_id", } - # TODO: set partner supplier rank + @mapping + def product_tmpl_id(self, record): + """Ensure a template is found.""" + # TODO: add test + value = None + if record.get("tmpl_default_code"): + handler = backend_to_rel( + "tmpl_default_code", + # See motiviation in product_product.mapper. + search_field="product_variant_ids.default_code", + ) + value = handler(record, "product_tmpl_id") + elif record.get("xid::product_tmpl_id"): + # Special case for when products are univocally identified via xid. + # TODO: try to get rid of this + # by allowing to specify backend_to_rel options via conf + tmpl_xid = sanitize_external_id(record.get("xid::product_tmpl_id")) + rec = self.env.ref(tmpl_xid, raise_if_not_found=False) + value = rec.id if rec else None + return {"product_tmpl_id": value} class ProductSupplierinfoRecordHandler(Component): diff --git a/connector_importer_product/data/import_type.xml b/connector_importer_product/data/import_type.xml index fa1648d80..bad5b02bb 100644 --- a/connector_importer_product/data/import_type.xml +++ b/connector_importer_product/data/import_type.xml @@ -66,10 +66,25 @@ Import Product Supplier Info product_supplierinfo +- model: res.partner + options: + importer: + odoo_unique_key: name + override_existing: false + mapper: + name: importer.mapper.dynamic + source_key_whitelist: supplier.name + source_key_rename: + partner_id: name + default_keys: + supplier_rank: 1 + - model: product.supplierinfo - importer: - name: - product.supplierinfo.importer + options: + importer: + odoo_unique_key: partner_id + mapper: + name: product.supplierinfo.mapper @@ -106,17 +121,29 @@ name: importer.mapper.dynamic source_key_prefix: supplier. source_key_whitelist: supplier.name + source_key_rename: + partner_id: name default_keys: supplier_rank: 1 - model: product.supplierinfo options: importer: - odoo_unique_key: name + odoo_unique_key: partner_id mapper: name: product.supplierinfo.mapper source_key_prefix: supplier. + + diff --git a/connector_importer_product/readme/ROADMAP.rst b/connector_importer_product/readme/ROADMAP.rst new file mode 100644 index 000000000..4af03b91b --- /dev/null +++ b/connector_importer_product/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* get rid of supplier info specific importer +* find a flexible way to define matching products diff --git a/connector_importer_product/tests/__init__.py b/connector_importer_product/tests/__init__.py index 5234e94a3..1ec62d987 100644 --- a/connector_importer_product/tests/__init__.py +++ b/connector_importer_product/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_product_category from . import test_product_attribute from . import test_product_packaging +from . import test_record_handler diff --git a/connector_importer_product/tests/common.py b/connector_importer_product/tests/common.py index 23d03f8c4..ae99434b6 100644 --- a/connector_importer_product/tests/common.py +++ b/connector_importer_product/tests/common.py @@ -20,8 +20,8 @@ class TestImportProductBase(TransactionComponentCase): def setUpClass(cls): super().setUpClass() cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) - backend = cls.env.ref("connector_importer_product.demo_import_backend") - backend.debug_mode = True # synchronous jobs + cls.backend = cls.env.ref("connector_importer_product.demo_import_backend") + cls.backend.debug_mode = True # synchronous jobs @classmethod def importer_load_file(cls, src_external_id, csv_filename): diff --git a/connector_importer_product/tests/test_record_handler.py b/connector_importer_product/tests/test_record_handler.py new file mode 100644 index 000000000..2eff958e1 --- /dev/null +++ b/connector_importer_product/tests/test_record_handler.py @@ -0,0 +1,158 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tools import DotDict + +from .common import TestImportProductBase + + +class TestProduct(TestImportProductBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.prod = cls.env["product.product"].create({"name": "Test prod"}) + cls.env["ir.model.data"].create( + { + "name": "variant_test_1", + "module": "__setup__", + "model": cls.prod._name, + "res_id": cls.prod.id, + "noupdate": False, + } + ) + cls.env["ir.model.data"].create( + { + "name": "tmpl_test_1", + "module": "__setup__", + "model": cls.prod.product_tmpl_id._name, + "res_id": cls.prod.product_tmpl_id.id, + "noupdate": False, + } + ) + cls.prod_attr = cls.env["product.attribute"].create({"name": "Size"}) + cls.prod_attr_imd = cls.env["ir.model.data"].create( + { + "name": "product_attr_Size", + "module": "__setup__", + "model": cls.prod_attr._name, + "res_id": cls.prod_attr.id, + "noupdate": False, + } + ) + cls.prod_attr_value_L = cls.env["product.attribute.value"].create( + {"attribute_id": cls.prod_attr.id, "name": "L"} + ) + cls.prod_attr_value_M = cls.env["product.attribute.value"].create( + { + "attribute_id": cls.prod_attr.id, + "name": "M", + } + ) + # conventional xid + cls.env["ir.model.data"].create( + { + "name": "product_attr_Size_value_M", + "module": "__setup__", + "model": cls.prod_attr_value_M._name, + "res_id": cls.prod_attr_value_M.id, + "noupdate": False, + } + ) + # custom xid + cls.env["ir.model.data"].create( + { + "name": "product_attr_SizeM", + "module": "__setup__", + "model": cls.prod_attr_value_M._name, + "res_id": cls.prod_attr_value_M.id, + "noupdate": False, + } + ) + + def _get_handler(self, options=None): + options = options or {"importer": {}, "mapper": {}, "record_handler": {}} + with self.backend.work_on( + "import.record", + options=DotDict(options), + ) as work: + return work.component_by_name( + "product.product.handler", model_name="product.product" + ) + + def test_find_attr_not_found(self): + self.prod_attr_imd.unlink() + handler = self._get_handler() + attr_column = "product_attr_Size" + orig_values = {attr_column: "L"} + with self.assertRaisesRegex( + ValueError, + "External ID not found in the system: __setup__.product_attr_Size", + ): + self.assertEqual( + handler._find_attr(attr_column, orig_values).name, "TEST_1" + ) + + def test_find_attr_found(self): + handler = self._get_handler() + attr_column = "product_attr_Size" + orig_values = {attr_column: "L"} + self.assertEqual(handler._find_attr(attr_column, orig_values), self.prod_attr) + + def test_find_or_create_attr_value_by_name(self): + handler = self._get_handler() + attr_column = "product_attr_Size" + orig_values = {attr_column: "L"} + self.assertEqual( + handler._find_or_create_attr_value( + self.prod_attr, attr_column, orig_values + ), + self.prod_attr_value_L, + ) + + def test_find_or_create_attr_value_by_xid_conventional(self): + handler = self._get_handler() + attr_column = "product_attr_Size" + # Value does not match name anymore, but it matched the conventional auto-computed xid + orig_values = {attr_column: "M"} + self.prod_attr_value_M.name = "Medium" + self.assertEqual( + handler._find_or_create_attr_value( + self.prod_attr, attr_column, orig_values + ), + self.prod_attr_value_M, + ) + + def test_find_or_create_attr_value_by_xid_custom(self): + handler = self._get_handler() + attr_column = "product_attr_Size" + # pass a specific value for xid + orig_values = {attr_column: "product_attr_SizeM"} + self.assertEqual( + handler._find_or_create_attr_value( + self.prod_attr, attr_column, orig_values + ), + self.prod_attr_value_M, + ) + + def test_find_or_create_attr_value_create_missing(self): + handler = self._get_handler( + options=dict(record_handler=dict(create_attribute_value_if_missing=True)) + ) + attr_column = "product_attr_Size" + orig_values = {attr_column: "XL"} + self.assertFalse( + self.env.ref( + "__setup__.product_attr_Size_value_XL", raise_if_not_found=False + ) + ) + self.assertEqual( + handler._find_or_create_attr_value( + self.prod_attr, attr_column, orig_values + ).name, + "XL", + ) + self.assertTrue( + self.env.ref( + "__setup__.product_attr_Size_value_XL", raise_if_not_found=False + ) + ) diff --git a/setup/connector_importer_product/odoo/addons/connector_importer_product b/setup/connector_importer_product/odoo/addons/connector_importer_product new file mode 120000 index 000000000..bfea37de6 --- /dev/null +++ b/setup/connector_importer_product/odoo/addons/connector_importer_product @@ -0,0 +1 @@ +../../../../connector_importer_product \ No newline at end of file diff --git a/setup/connector_importer_product/setup.py b/setup/connector_importer_product/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/connector_importer_product/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)