From 79d26dcbc7f7d6ce3f54da56131f1b8de9b4ab36 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 May 2023 11:06:04 +0200 Subject: [PATCH 01/16] connector_importer: dynamic mapper blacklist --- connector_importer/components/dynamicmapper.py | 7 +++++++ connector_importer/tests/test_mapper.py | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/connector_importer/components/dynamicmapper.py b/connector_importer/components/dynamicmapper.py index 2857951cf..b1316e2bc 100644 --- a/connector_importer/components/dynamicmapper.py +++ b/connector_importer/components/dynamicmapper.py @@ -82,6 +82,9 @@ def _get_valid_keys(self, record): whitelist = self._source_key_whitelist if whitelist: valid_keys = [k for k in valid_keys if k in whitelist] + blacklist = self._source_key_blacklist + if blacklist: + valid_keys = [k for k in valid_keys if k not in blacklist] return tuple(valid_keys) def _required_keys(self): @@ -91,6 +94,10 @@ def _required_keys(self): def _source_key_whitelist(self): return self.work.options.mapper.get("source_key_whitelist", []) + @property + def _source_key_blacklist(self): + return self.work.options.mapper.get("source_key_blacklist", []) + @property def _source_key_empty_skip(self): """List of source keys to skip when empty. diff --git a/connector_importer/tests/test_mapper.py b/connector_importer/tests/test_mapper.py index 628e0d457..770a914e0 100644 --- a/connector_importer/tests/test_mapper.py +++ b/connector_importer/tests/test_mapper.py @@ -61,6 +61,14 @@ def test_dynamic_mapper_clean_record(self): "ref": "12345", } self.assertEqual(mapper._clean_record(rec), expected) + # Blacklist + mapper = self._get_dynamyc_mapper(options=dict(source_key_blacklist=["ref"])) + expected = { + "name": "John Doe", + "some_one": 1, + "some_two": 2, + } + self.assertEqual(mapper._clean_record(rec), expected) # Prefix mapper = self._get_dynamyc_mapper(options=dict(source_key_prefix="some_")) expected = { From 7e70bd2b9f5da1e07e24fa1cb3d1b33ce8752bf0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 9 May 2023 17:44:48 +0200 Subject: [PATCH 02/16] connector_importer: allow custom find domain You can now configure a custom domain on record_handler options. Eg: options: record_handler: match_domain: "[('name', =, values['name'])]" When defined, it will take precedence over the unique key if any. --- connector_importer/components/odoorecord.py | 33 ++++++++- connector_importer/tests/__init__.py | 1 + .../tests/test_record_handler.py | 73 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 connector_importer/tests/test_record_handler.py diff --git a/connector_importer/components/odoorecord.py b/connector_importer/components/odoorecord.py index ad1e60530..9345cb23b 100644 --- a/connector_importer/components/odoorecord.py +++ b/connector_importer/components/odoorecord.py @@ -2,6 +2,8 @@ # Copyright 2018 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tools import safe_eval + from odoo.addons.component.core import Component from ..utils.misc import sanitize_external_id @@ -38,6 +40,35 @@ def unique_key_is_xmlid(self): def odoo_find_domain(self, values, orig_values): """Domain to find the record in odoo.""" + domain = self._odoo_find_domain_from_options(values, orig_values) + if not domain: + domain = self._odoo_find_domain_from_unique_key(values, orig_values) + return domain + + def _odoo_find_domain_from_options(self, values, orig_values): + """Evaluate domain from options if any.""" + match_domain = self.work.options.record_handler.match_domain + if not match_domain: + return [] + eval_ctx = self._domain_from_options_eval_ctx(values, orig_values) + domain = safe_eval.safe_eval( + self.work.options.record_handler.match_domain, eval_ctx + ) + if not isinstance(domain, list): + raise ValueError("match_domain must be a list") + return domain + + def _domain_from_options_eval_ctx(self, values, orig_values): + return { + "user": self.env.user, + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "time": safe_eval.time, + "values": values, + "orig_values": orig_values, + } + + def _odoo_find_domain_from_unique_key(self, values, orig_values): value = NO_VALUE if self.unique_key in values: value = values[self.unique_key] @@ -45,7 +76,7 @@ def odoo_find_domain(self, values, orig_values): value = orig_values[self.unique_key] if value is NO_VALUE: raise ValueError( - f"Cannot find {self.unique_key} in `values` nor `orig_values`" + f"Cannot find `{self.unique_key}` key in `values` nor `orig_values`" ) return [(self.unique_key, "=", value)] diff --git a/connector_importer/tests/__init__.py b/connector_importer/tests/__init__.py index e33f6f563..445880664 100644 --- a/connector_importer/tests/__init__.py +++ b/connector_importer/tests/__init__.py @@ -5,6 +5,7 @@ from . import test_record_importer from . import test_record_importer_basic from . import test_record_importer_xmlid +from . import test_record_handler from . import test_source from . import test_source_csv from . import test_mapper diff --git a/connector_importer/tests/test_record_handler.py b/connector_importer/tests/test_record_handler.py new file mode 100644 index 000000000..e35f7824b --- /dev/null +++ b/connector_importer/tests/test_record_handler.py @@ -0,0 +1,73 @@ +# Author: Simone Orsi +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.tools import DotDict + +from .common import TestImporterBase + +values = { + "name": "John", + "age": 40, +} +orig_values = { + "Name": "John ", + "Age": "40", +} + + +class TestRecordImporter(TestImporterBase): + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.record = cls.env["import.record"].create({"recordset_id": cls.recordset.id}) + + def _get_components(self): + from .fake_components import PartnerMapper, PartnerRecordImporter + + return [PartnerRecordImporter, PartnerMapper] + + def _get_handler(self): + with self.backend.work_on( + self.record._name, + components_registry=self.comp_registry, + options=DotDict({"record_handler": {}}), + ) as work: + return work.component(usage="odoorecord.handler", model_name="res.partner") + + def test_match_domain(self): + handler = self._get_handler() + domain = handler._odoo_find_domain_from_options(values, orig_values) + self.assertEqual(domain, []) + handler.work.options["record_handler"] = { + "match_domain": "[('name', '=', values['name']), ('age', '=', orig_values['Age'])]" + } + domain = handler._odoo_find_domain_from_options(values, orig_values) + self.assertEqual( + domain, [("name", "=", values["name"]), ("age", "=", orig_values["Age"])] + ) + + def test_unique_key_domain(self): + handler = self._get_handler() + handler.unique_key = "nowhere" + with self.assertRaises(ValueError): + domain = handler._odoo_find_domain_from_unique_key(values, orig_values) + handler.unique_key = "name" + domain = handler._odoo_find_domain_from_unique_key(values, orig_values) + self.assertEqual(domain, [("name", "=", values["name"])]) + handler.unique_key = "Name" + domain = handler._odoo_find_domain_from_unique_key(values, orig_values) + self.assertEqual(domain, [("Name", "=", orig_values["Name"])]) + + def test_find_domain(self): + handler = self._get_handler() + handler.unique_key = "age" + domain = handler.odoo_find_domain(values, orig_values) + self.assertEqual(domain, [("age", "=", values["age"])]) + handler.work.options["record_handler"] = { + "match_domain": "[('name', '=', values['name']), ('age', '=', values['age'])]" + } + domain = handler.odoo_find_domain(values, orig_values) + self.assertEqual( + domain, [("name", "=", values["name"]), ("age", "=", values["age"])] + ) From 4c6e73bfc2a12f6bd888c680e1d4c4dd7811461f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 May 2023 12:14:58 +0200 Subject: [PATCH 03/16] connector_importer: fix odoo_find Before this change if you did not override 'odoo_find' itself your custom odoo_find_domain would be bypassed if you had an empty unique_key. This was a clear issue when you specified only 'options.record_handler.match_domain'. --- connector_importer/components/odoorecord.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/connector_importer/components/odoorecord.py b/connector_importer/components/odoorecord.py index 9345cb23b..51f5ecc3f 100644 --- a/connector_importer/components/odoorecord.py +++ b/connector_importer/components/odoorecord.py @@ -82,13 +82,11 @@ def _odoo_find_domain_from_unique_key(self, values, orig_values): def odoo_find(self, values, orig_values): """Find any existing item in odoo.""" - if self.unique_key == "": + if self.unique_key and self.unique_key_is_xmlid: # if unique_key is None we might use as special find domain - return self.model - if self.unique_key_is_xmlid: xid = self._get_xmlid(values, orig_values) item = self.env.ref(xid, raise_if_not_found=False) - return item + return item or self.model item = self.model.search( self.odoo_find_domain(values, orig_values), order="create_date desc", From 3d690bd4ad7948703f1801af89b86cbbe8e3a758 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 May 2023 16:48:05 +0200 Subject: [PATCH 04/16] connector_importer: fix xmlid_to_rel w/ x2m Values where not properly converted for x2m fields. --- connector_importer/tests/test_mapper.py | 9 ++++++--- connector_importer/utils/mapper_utils.py | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/connector_importer/tests/test_mapper.py b/connector_importer/tests/test_mapper.py index 770a914e0..73a65c616 100644 --- a/connector_importer/tests/test_mapper.py +++ b/connector_importer/tests/test_mapper.py @@ -105,18 +105,21 @@ def test_dynamic_mapper_values(self): expected = rec.copy() self.assertEqual(mapper.dynamic_fields(rec), expected) mapper = self._get_dynamyc_mapper() - cat = self.env.ref("base.res_partner_category_0") + categs = self.env.ref("base.res_partner_category_0") + self.env.ref( + "base.res_partner_category_2" + ) rec = { "name": "John Doe", "ref": "12345", "xid::parent_id": "base.res_partner_10", - "category_id": cat.name, + "xid::category_id": "base.res_partner_category_0,base.res_partner_category_2", + "title_id": "Doctor", } expected = { "name": "John Doe", "ref": "12345", "parent_id": self.env.ref("base.res_partner_10").id, - "category_id": [(6, 0, cat.ids)], + "category_id": [(6, 0, categs.ids)], } self.assertEqual(mapper.dynamic_fields(rec), expected) diff --git a/connector_importer/utils/mapper_utils.py b/connector_importer/utils/mapper_utils.py index 8ca1bb62c..27a7d6518 100644 --- a/connector_importer/utils/mapper_utils.py +++ b/connector_importer/utils/mapper_utils.py @@ -169,21 +169,25 @@ def modifier(self, record, to_attr): value = record.get(field) if value is None: return None - if isinstance(value, str) and "," in value: - value = [x.strip() for x in value.split(",") if x.strip()] - if isinstance(value, str): + column = self.model._fields[to_attr] + if column.type.endswith("2many"): + _values = [x.strip() for x in value.split(",") if x.strip()] + values = [] + rec_ids = [] + for xid in _values: + rec = _xid_to_record(self.env, xid) + if rec: + rec_ids.append(rec.id) + values.append((6, 0, rec_ids)) + return values + elif column.type.endswith("many2one"): # m2o rec = _xid_to_record(self.env, value) if rec: return rec.id return None - # x2m - values = [] - for xid in value: - rec = _xid_to_record(self.env, xid) - if rec: - values.append((6, 0, rec.ids)) - return values + else: + raise ValueError("Destination is not a related field.") modifier._from_key = field return modifier From 43c20af87872412f2712708866d9b1825d08d81d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 15 May 2023 09:38:15 +0200 Subject: [PATCH 05/16] connector_importer: raise exc if no domain and no uniq key --- connector_importer/components/odoorecord.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/connector_importer/components/odoorecord.py b/connector_importer/components/odoorecord.py index 51f5ecc3f..44d3208c8 100644 --- a/connector_importer/components/odoorecord.py +++ b/connector_importer/components/odoorecord.py @@ -42,6 +42,8 @@ def odoo_find_domain(self, values, orig_values): """Domain to find the record in odoo.""" domain = self._odoo_find_domain_from_options(values, orig_values) if not domain: + if not self.unique_key: + raise ValueError("No unique key and no domain to find this record") domain = self._odoo_find_domain_from_unique_key(values, orig_values) return domain From 73bae7674029dc61cf0ab3a21abdb6789a690db1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 15 May 2023 09:39:14 +0200 Subject: [PATCH 06/16] connector_importer: improve domain eval ctx You can now use ref and env to compute your domain. --- connector_importer/components/odoorecord.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/connector_importer/components/odoorecord.py b/connector_importer/components/odoorecord.py index 44d3208c8..5cf255e55 100644 --- a/connector_importer/components/odoorecord.py +++ b/connector_importer/components/odoorecord.py @@ -62,12 +62,15 @@ def _odoo_find_domain_from_options(self, values, orig_values): def _domain_from_options_eval_ctx(self, values, orig_values): return { + "env": self.env, "user": self.env.user, "datetime": safe_eval.datetime, "dateutil": safe_eval.dateutil, "time": safe_eval.time, "values": values, "orig_values": orig_values, + "ref_id": lambda x: self._smart_ref(x).id, + "ref": lambda x: self._smart_ref(x), } def _odoo_find_domain_from_unique_key(self, values, orig_values): @@ -96,6 +99,9 @@ def odoo_find(self, values, orig_values): ) return item + def _smart_ref(self, xid): + return self.env.ref(sanitize_external_id(xid)) + def _get_xmlid(self, values, orig_values): # Mappers will remove `xid::` prefix from the final values # hence, look for the original key. From 4d2a0492870c4f9710603dbd1d8a3dc58f129462 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 15 May 2023 10:01:30 +0200 Subject: [PATCH 07/16] connector_importer: fix warning in tests --- connector_importer/tests/test_record_importer_xmlid.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/connector_importer/tests/test_record_importer_xmlid.py b/connector_importer/tests/test_record_importer_xmlid.py index 70763689e..f21515b79 100644 --- a/connector_importer/tests/test_record_importer_xmlid.py +++ b/connector_importer/tests/test_record_importer_xmlid.py @@ -31,7 +31,9 @@ def test_importer_create(self): { "options": """ - model: res.partner - importer: fake.partner.importer.xmlid + importer: + name: + fake.partner.importer.xmlid """ } ) From 40548712edef5402384a2add32afe03a02f21973 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 May 2023 12:33:36 +0200 Subject: [PATCH 08/16] connector_importer: fix handling of xid:: + prefix Keys having both prefix and a transformer like 'xid::' were discarded. A key like 'xid::foo.parent_id' would simply be ignored. --- .../components/dynamicmapper.py | 10 ++++----- connector_importer/tests/test_mapper.py | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/connector_importer/components/dynamicmapper.py b/connector_importer/components/dynamicmapper.py index b1316e2bc..fc6e3ff5c 100644 --- a/connector_importer/components/dynamicmapper.py +++ b/connector_importer/components/dynamicmapper.py @@ -34,14 +34,14 @@ def dynamic_fields(self, record): # Never convert IDs continue fname = source_fname - if prefix and source_fname.startswith(prefix): - # Eg: prefix all supplier fields w/ `supplier_` - fname = fname[len(prefix) :] - clean_record[fname] = clean_record.pop(source_fname) if "::" in fname: # Eg: transformers like `xid::`` fname = fname.split("::")[-1] clean_record[fname] = clean_record.pop(source_fname) + if prefix and fname.startswith(prefix): + # Eg: prefix all supplier fields w/ `supplier.` + fname = fname[len(prefix) :] + clean_record[fname] = clean_record.pop(prefix + fname) if available_fields.get(fname): fspec = available_fields.get(fname) ftype = fspec["type"] @@ -78,7 +78,7 @@ def _get_valid_keys(self, record): valid_keys = [k for k in record.keys() if not k.startswith("_")] prefix = self._source_key_prefix if prefix: - valid_keys = [k for k in valid_keys if k.startswith(prefix)] + valid_keys = [k for k in valid_keys if prefix in k] whitelist = self._source_key_whitelist if whitelist: valid_keys = [k for k in valid_keys if k in whitelist] diff --git a/connector_importer/tests/test_mapper.py b/connector_importer/tests/test_mapper.py index 73a65c616..58817573a 100644 --- a/connector_importer/tests/test_mapper.py +++ b/connector_importer/tests/test_mapper.py @@ -123,6 +123,27 @@ def test_dynamic_mapper_values(self): } self.assertEqual(mapper.dynamic_fields(rec), expected) + def test_dynamic_mapper_values_with_prefix(self): + mapper = self._get_dynamyc_mapper(options=dict(source_key_prefix="foo.")) + rec = {} + expected = {} + categs = self.env.ref("base.res_partner_category_0") + self.env.ref( + "base.res_partner_category_2" + ) + rec = { + "foo.name": "John Doe", + "ref": "12345", + "xid::foo.parent_id": "base.res_partner_10", + "xid::foo.category_id": "base.res_partner_category_0,base.res_partner_category_2", + "title_id": "Doctor", + } + expected = { + "name": "John Doe", + "parent_id": self.env.ref("base.res_partner_10").id, + "category_id": [(6, 0, categs.ids)], + } + self.assertEqual(mapper.dynamic_fields(rec), expected) + def test_dynamic_mapper_skip_empty(self): rec = { "name": "John Doe", From 128af79fe133c6e3966798aa7aed32a3ce27b8db Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 5 Jun 2023 14:39:34 +0200 Subject: [PATCH 09/16] connector_importer: allow pass options to converter You can now pass options for specific fields converter via conf. If for instance, the model you are importing has a relation `partner_id` you can state that missing records must be created automatically. Eg:: - model: product.product options: importer: odoo_unique_key: barcode mapper: name: product.product.mapper converter: categ_id: create_missing: True All the keys inside converted/field will be propagated to the `backend_to_rel` converter. --- .../components/dynamicmapper.py | 30 +++++++--------- connector_importer/tests/test_mapper.py | 34 +++++++++++++++++-- connector_importer/utils/mapper_utils.py | 7 ++-- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/connector_importer/components/dynamicmapper.py b/connector_importer/components/dynamicmapper.py index fc6e3ff5c..ea4be5d9a 100644 --- a/connector_importer/components/dynamicmapper.py +++ b/connector_importer/components/dynamicmapper.py @@ -113,11 +113,6 @@ def _source_key_empty_skip(self): def _source_key_prefix(self): return self.work.options.mapper.get("source_key_prefix", "") - @property - def _source_key_xid_module(self): - """Module name to use to sanitize XMLids""" - return self.work.options.mapper.get("source_key_xid_module", "") - def _is_xmlid_key(self, fname, ftype): return fname.startswith("xid::") and ftype in ( "many2one", @@ -125,26 +120,25 @@ def _is_xmlid_key(self, fname, ftype): "many2many", ) - def _dynamic_keys_mapping(self, fname): + def _dynamic_keys_mapping(self, fname, **options): return { "char": lambda self, rec, fname: rec[fname], "text": lambda self, rec, fname: rec[fname], "selection": lambda self, rec, fname: rec[fname], - "integer": convert(fname, "safe_int"), - "float": convert(fname, "safe_float"), - "boolean": convert(fname, "bool"), - "date": convert(fname, "date"), - "datetime": convert(fname, "utc_date"), - "many2one": backend_to_rel(fname), - "many2many": backend_to_rel(fname), - "one2many": backend_to_rel(fname), - "_xmlid": xmlid_to_rel( - fname, sanitize_default_mod_name=self._source_key_xid_module - ), + "integer": convert(fname, "safe_int", **options), + "float": convert(fname, "safe_float", **options), + "boolean": convert(fname, "bool", **options), + "date": convert(fname, "date", **options), + "datetime": convert(fname, "utc_date", **options), + "many2one": backend_to_rel(fname, **options), + "many2many": backend_to_rel(fname, **options), + "one2many": backend_to_rel(fname, **options), + "_xmlid": xmlid_to_rel(fname, **options), } def _get_converter(self, fname, ftype): - return self._dynamic_keys_mapping(fname).get(ftype) + options = self.work.options.mapper.get("converter", {}).get(fname, {}) + return self._dynamic_keys_mapping(fname, **options).get(ftype) _non_mapped_keys_cache = None diff --git a/connector_importer/tests/test_mapper.py b/connector_importer/tests/test_mapper.py index 58817573a..1a82a00a7 100644 --- a/connector_importer/tests/test_mapper.py +++ b/connector_importer/tests/test_mapper.py @@ -4,6 +4,11 @@ from odoo.tools import DotDict +try: + from odoo.tests.common import RecordCapturer +except ImportError: + from odoo.addons.connector_importer.tests.record_capturer import RecordCapturer + from .common import TestImporterBase MOD_PATH = "odoo.addons.connector_importer" @@ -18,11 +23,11 @@ def _setup_records(cls): return res def _get_importer(self, options=None): - options = options or DotDict({"importer": {}, "mapper": {}}) + options = options or {"importer": {}, "mapper": {}} with self.backend.work_on( self.record._name, components_registry=self.comp_registry, - options=options, + options=DotDict(options), ) as work: return work.component_by_name("importer.record", model_name="res.partner") @@ -155,3 +160,28 @@ def test_dynamic_mapper_skip_empty(self): } mapper = self._get_dynamyc_mapper(options=dict(source_key_empty_skip=["ref"])) self.assertEqual(mapper.dynamic_fields(rec), expected) + + def test_rel_create_if_missing(self): + opts = { + "parent_id": {"create_missing": True}, + "category_id": {"create_missing": True}, + } + mapper = self._get_dynamyc_mapper(options=dict(converter=opts)) + rec = { + "name": "John Doe", + "ref": "12345", + "parent_id": "Parent of J. Doe", + "category_id": "New category", + } + with RecordCapturer( + self.env["res.partner"].sudo(), [] + ) as partner_capt, RecordCapturer( + self.env["res.partner.category"].sudo(), [] + ) as cat_capt: + res = mapper.dynamic_fields(rec) + parent = partner_capt.records + cat = cat_capt.records + self.assertEqual(parent.name, "Parent of J. Doe") + self.assertEqual(cat.name, "New category") + self.assertEqual(res["parent_id"], parent.id) + self.assertEqual(res["category_id"], [(6, 0, [cat.id])]) diff --git a/connector_importer/utils/mapper_utils.py b/connector_importer/utils/mapper_utils.py index 27a7d6518..c5c4ace83 100644 --- a/connector_importer/utils/mapper_utils.py +++ b/connector_importer/utils/mapper_utils.py @@ -121,7 +121,7 @@ def modifier(self, record, to_attr): return modifier -def from_mapping(field, mapping, default_value=None): +def from_mapping(field, mapping, default_value=None, **kw): """Convert the source value using a ``mapping`` of values.""" def modifier(self, record, to_attr): @@ -132,7 +132,7 @@ def modifier(self, record, to_attr): return modifier -def concat(field, separator=" ", handler=None): +def concat(field, separator=" ", handler=None, **kw): """Concatenate values from different fields.""" # TODO: `field` is actually a list of fields. @@ -150,7 +150,7 @@ def modifier(self, record, to_attr): return modifier -def xmlid_to_rel(field, sanitize=True, sanitize_default_mod_name=None): +def xmlid_to_rel(field, sanitize=True, sanitize_default_mod_name=None, **kw): """Convert xmlids source values to ids.""" xmlid_to_rel._sanitize = sanitize xmlid_to_rel._sanitize_default_mod_name = sanitize_default_mod_name @@ -208,6 +208,7 @@ def backend_to_rel( # noqa: C901 allowed_length=None, create_missing=False, create_missing_handler=None, + **kw ): """A modifier intended to be used on the ``direct`` mappings. From 3eff2b6d2584678b08bc879710b32ec75e19208f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Jun 2023 11:04:00 +0200 Subject: [PATCH 10/16] c_importer: improve trigger events --- connector_importer/components/importer.py | 37 ++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/connector_importer/components/importer.py b/connector_importer/components/importer.py index c2ca59ad7..e2c99326a 100644 --- a/connector_importer/components/importer.py +++ b/connector_importer/components/importer.py @@ -394,17 +394,32 @@ def run(self, record, is_last_importer=True, **kw): ] ).format(**counters) self.tracker._log(msg) - self._trigger_finish_events(record, is_last_importer=is_last_importer) + self.finalize_session(record, is_last_importer=is_last_importer) return counters - def _trigger_finish_events(self, record, is_last_importer=False): - """Trigger events when the importer has done its job.""" + def finalize_session(self, record, is_last_importer=False): + self._trigger_importer_events(record) if is_last_importer: - # Trigger global event for recordset - self.recordset._event( - "on_last_record_import_finished", collection=self.work.collection - ).notify(self, record) - # Trigger model specific event - self.model.browse()._event( - "on_last_record_import_finished", collection=self.work.collection - ).notify(self, record) + self._trigger_finish_events(record) + + def _trigger_importer_events(self, record): + """Trigger events when the importer has done its job.""" + # Trigger global event for recordset + self.recordset._event( + "on_record_import_finished", collection=self.work.collection + ).notify(self, record) + # Trigger model specific event + self.model.browse()._event( + "on_record_import_finished", collection=self.work.collection + ).notify(self, record) + + def _trigger_finish_events(self, record): + """Trigger events when the importer has done its job.""" + # Trigger global event for recordset + self.recordset._event( + "on_last_record_import_finished", collection=self.work.collection + ).notify(self, record) + # Trigger model specific event + self.model.browse()._event( + "on_last_record_import_finished", collection=self.work.collection + ).notify(self, record) From d66c4ab3192fb642fbc383fdb07c7424797dba21 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Jun 2023 11:05:12 +0200 Subject: [PATCH 11/16] c_importer: add recordset.get_report_by_model To ease retrieval of import session reports. --- connector_importer/models/recordset.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/connector_importer/models/recordset.py b/connector_importer/models/recordset.py index 46ac1fd26..11c571fb5 100644 --- a/connector_importer/models/recordset.py +++ b/connector_importer/models/recordset.py @@ -164,17 +164,33 @@ def _get_report_html_data(self): data = { "recordset": self, "last_start": report.pop("_last_start"), - "report_by_model": OrderedDict(), + "report_by_model": self._get_report_by_model(), } + return data + + def _get_report_by_model(self, counters_only=True): + report = self.get_report() + value_handler = ( + len if counters_only else lambda vals: [x["odoo_record"] for x in vals] + ) + res = OrderedDict() # count keys by model for config in self.available_importers(): model = self.env["ir.model"]._get(config.model) - data["report_by_model"][model] = {} + res[model] = {} # be defensive here. At some point # we could decide to skip models on demand. for k, v in report.get(config.model, {}).items(): - data["report_by_model"][model][k] = len(v) - return data + res[model][k] = value_handler(v) + return res + + def get_report_by_model(self, model_name=None): + report = self._get_report_by_model(counters_only=False) + if model_name: + report = { + k.model: v for k, v in report.items() if k.model == model_name + }.get(model_name, {}) + return report @api.depends("report_data") def _compute_report_html(self): From 7c36f36104a5ccf88ea2593a4ba78ad3adeb7673 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Jun 2023 11:06:22 +0200 Subject: [PATCH 12/16] c_importer: allow exec server actions on recordset --- connector_importer/components/__init__.py | 1 + connector_importer/components/listeners.py | 81 ++++++++ connector_importer/models/recordset.py | 45 ++++ connector_importer/tests/__init__.py | 1 + connector_importer/tests/fake_components.py | 8 + connector_importer/tests/fake_models.py | 9 + .../tests/test_event_listeners.py | 193 ++++++++++++++++++ connector_importer/tests/test_recordset.py | 19 ++ connector_importer/views/recordset_views.xml | 25 +++ 9 files changed, 382 insertions(+) create mode 100644 connector_importer/components/listeners.py create mode 100644 connector_importer/tests/test_event_listeners.py diff --git a/connector_importer/components/__init__.py b/connector_importer/components/__init__.py index 678a6d069..da0aecae2 100644 --- a/connector_importer/components/__init__.py +++ b/connector_importer/components/__init__.py @@ -7,3 +7,4 @@ from . import mapper from . import automapper from . import dynamicmapper +from . import listeners diff --git a/connector_importer/components/listeners.py b/connector_importer/components/listeners.py new file mode 100644 index 000000000..19ffb57d8 --- /dev/null +++ b/connector_importer/components/listeners.py @@ -0,0 +1,81 @@ +# Copyright 2023 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from functools import partial + +from odoo.addons.component.core import Component + + +class ImportRecordsetEventListener(Component): + _name = "recordset.event.listener" + _inherit = "base.connector.listener" + _apply_on = ["import.recordset"] + + def on_last_record_import_finished(self, importer, record): + if self._must_run_server_action(importer, record, "last_importer_done"): + self._run_server_actions(importer, record) + + def on_record_import_finished(self, importer, record): + if self._must_run_server_action(importer, record, "each_importer_done"): + self._run_server_actions(importer, record) + + def _must_run_server_action(self, importer, record, trigger): + recordset = record.recordset_id + return bool( + recordset.server_action_ids + and recordset.server_action_trigger_on == trigger + and self._has_records_to_process(importer) + ) + + def _has_records_to_process(self, importer): + counters = importer.tracker.get_counters() + return counters["created"] or counters["updated"] + + def _run_server_actions(self, importer, record): + """Execute one or more server actions tied to the recordset.""" + recordset = record.recordset_id + actions = recordset.server_action_ids + report_by_model = recordset.get_report_by_model() + # execute actions by importer order + for model, report in report_by_model.items(): + action = actions.filtered(lambda x: x.model_id == model) + if not action: + continue + record_ids = sorted(set(report["created"] + report["updated"])) + if not record_ids: + continue + self._add_after_commit_hook(recordset.id, action.id, record_ids) + generic_action = actions.filtered( + lambda x: x.model_id.model == "import.recordset" + ) + if generic_action: + self._add_after_commit_hook(recordset.id, generic_action.id, recordset.ids) + + def _run_server_action(self, recordset_id, action_id, record_ids): + action = self.env["ir.actions.server"].browse(action_id) + action = action.with_context( + **self._run_server_action_ctx(recordset_id, action_id, record_ids) + ) + return action.run() + + def _run_server_action_ctx(self, recordset_id, action_id, record_ids): + action = self.env["ir.actions.server"].browse(action_id) + action_ctx = dict( + active_model=action.model_id.model, import_recordset_id=recordset_id + ) + if len(record_ids) > 1: + action_ctx["active_ids"] = record_ids + else: + action_ctx["active_id"] = record_ids[0] + return action_ctx + + def _add_after_commit_hook(self, recordset_id, action_id, record_ids): + self.env.cr.postcommit.add( + partial( + self._run_server_action_post_commit, recordset_id, action_id, record_ids + ), + ) + + def _run_server_action_post_commit(self, recordset_id, action_id, record_ids): + self._run_server_action(recordset_id, action_id, record_ids) + self.env.cr.commit() # pylint: disable=invalid-commit diff --git a/connector_importer/models/recordset.py b/connector_importer/models/recordset.py index 11c571fb5..3c920f408 100644 --- a/connector_importer/models/recordset.py +++ b/connector_importer/models/recordset.py @@ -82,11 +82,56 @@ class ImportRecordset(models.Model): docs_html = fields.Html(string="Docs", compute="_compute_docs_html") notes = fields.Html(help="Useful info for your users") last_run_on = fields.Datetime() + server_action_trigger_on = fields.Selection( + selection=[ + ("never", "Never"), + ("last_importer_done", "End of the whole import"), + ("each_importer_done", "End of each importer session"), + ], + default="never", + ) + server_action_ids = fields.Many2many( + "ir.actions.server", + string="Executre server actions", + help=( + "Execute a server action when done. " + "You can link a server action per model or a single one for import.recordset. " + "In that case you'll have to use low level api " + "to get the records that were processed. " + "Eg: `get_report_by_model`." + ), + ) + server_action_importable_model_ids = fields.Many2many( + comodel_name="ir.model", + compute="_compute_importable_model_ids", + relation="import_recordset_server_action_importable_model", + column1="recordset_id", + column2="model_id", + help="Technical field", + ) + importable_model_ids = fields.Many2many( + comodel_name="ir.model", + compute="_compute_importable_model_ids", + relation="import_recordset_importable_model", + column1="recordset_id", + column2="model_id", + help="Technical field", + ) def _compute_name(self): for item in self: item.name = f"#{item.id}" + @api.depends("import_type_id.options") + def _compute_importable_model_ids(self): + _get = self.env["ir.model"]._get + for rec in self: + for config in rec.available_importers(): + rec.importable_model_ids |= _get(config.model) + rec.server_action_importable_model_ids = ( + _get(self._name) + rec.importable_model_ids + ) + def get_records(self): """Retrieve importable records and keep ordering.""" return self.env["import.record"].search([("recordset_id", "=", self.id)]) diff --git a/connector_importer/tests/__init__.py b/connector_importer/tests/__init__.py index 445880664..4af0ae040 100644 --- a/connector_importer/tests/__init__.py +++ b/connector_importer/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_source from . import test_source_csv from . import test_mapper +from . import test_event_listeners diff --git a/connector_importer/tests/fake_components.py b/connector_importer/tests/fake_components.py index 84c4f6d8d..dbb6ac14b 100644 --- a/connector_importer/tests/fake_components.py +++ b/connector_importer/tests/fake_components.py @@ -68,3 +68,11 @@ def prepare_line(self, line): return res write_context = create_context + + +class FakeModelMapper(Component): + _name = "fake.model.mapper" + _inherit = "importer.base.mapper" + _apply_on = "fake.imported.model" + + direct = [("fullname", "name")] diff --git a/connector_importer/tests/fake_models.py b/connector_importer/tests/fake_models.py index bd8252723..c1b08a458 100644 --- a/connector_importer/tests/fake_models.py +++ b/connector_importer/tests/fake_models.py @@ -39,3 +39,12 @@ def _get_lines(self): def _sort_lines(self, lines): return reversed(list(lines)) + + +class FakeImportedModel(models.Model): + + _name = "fake.imported.model" + _description = _name + _description = "Fake model" + + name = fields.Char() diff --git a/connector_importer/tests/test_event_listeners.py b/connector_importer/tests/test_event_listeners.py new file mode 100644 index 000000000..17bfb4112 --- /dev/null +++ b/connector_importer/tests/test_event_listeners.py @@ -0,0 +1,193 @@ +# Author: Simone Orsi +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import mock +from odoo_test_helper import FakeModelLoader + +from odoo.tools import mute_logger + +from odoo.addons.component.core import WorkContext + +from .common import TestImporterBase + +MOD_PATH = "odoo.addons.connector_importer" +LISTENER_PATH = MOD_PATH + ".components.listeners.ImportRecordsetEventListener" +MOCKED_LOG_ENTRIES = [] + + +class TestRecordImporter(TestImporterBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + # fmt: off + from .fake_models import FakeImportedModel + cls.loader.update_registry((FakeImportedModel,)) + cls.fake_imported_model = cls.env[FakeImportedModel._name] + # fmt: on + # generate 20 records + cls.fake_lines = cls._fake_lines(cls, 20, keys=("id", "fullname")) + cls.action_recset = cls.env["ir.actions.server"].create( + { + "name": "Run after import - recordset", + "model_id": cls.env.ref("connector_importer.model_import_recordset").id, + "state": "code", + "code": """ +msg = "Exec for recordset: " + str(recordset.id) +log(msg) + """, + } + ) + cls.action_partner = cls.env["ir.actions.server"].create( + { + "name": "Run after import - partner", + "model_id": cls.env.ref("base.model_res_partner").id, + "state": "code", + "code": """ +msg = "Exec for recordset: " + str(env.context["import_recordset_id"]) +msg += ". Partners: " + str(records.ids) +log(msg) + """, + } + ) + cls.import_type.write( + { + "options": f""" +- model: res.partner + importer: fake.partner.importer +- model: {FakeImportedModel._name} + options: + record_handler: + match_domain: "[('name', '=', values['name'])]" + """ + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def setUp(self): + super().setUp() + # The components registry will be handled by the + # `import.record.import_record()' method when initializing its + # WorkContext + self.record = self.env["import.record"].create( + {"recordset_id": self.recordset.id} + ) + self.record.set_data(self.fake_lines) + global MOCKED_LOG_ENTRIES + MOCKED_LOG_ENTRIES = [] + + def _get_components(self): + from .fake_components import ( + FakeModelMapper, + PartnerMapper, + PartnerRecordImporter, + ) + + return [PartnerRecordImporter, PartnerMapper, FakeModelMapper] + + @mute_logger("[importer]") + def test_server_action_no_trigger(self): + with mock.patch(LISTENER_PATH + "._add_after_commit_hook") as mocked: + self.record.run_import() + mocked.assert_not_called() + + @mute_logger("[importer]") + def test_server_action_trigger_last_1_action(self): + self.recordset.server_action_ids += self.action_recset + self.recordset.server_action_trigger_on = "last_importer_done" + mocked_hook = mock.patch(LISTENER_PATH + "._add_after_commit_hook") + with mocked_hook as mocked: + self.record.run_import() + self.assertEqual(mocked.call_count, 1) + self.assertEqual( + mocked.call_args[0], + (self.recordset.id, self.action_recset.id, [self.recordset.id]), + ) + + @mute_logger("[importer]") + def test_server_action_trigger_last_2_actions(self): + self.recordset.server_action_ids += self.action_recset + self.recordset.server_action_ids += self.action_partner + self.recordset.server_action_trigger_on = "last_importer_done" + mocked_hook = mock.patch(LISTENER_PATH + "._add_after_commit_hook") + with mocked_hook as mocked: + self.record.run_import() + self.assertEqual(mocked.call_count, 2) + partner_report = self.recordset.get_report_by_model("res.partner") + record_ids = sorted( + set(partner_report["created"] + partner_report["updated"]) + ) + self.assertEqual( + mocked.call_args_list[0][0], + (self.recordset.id, self.action_partner.id, record_ids), + ) + self.assertEqual( + mocked.call_args_list[1][0], + (self.recordset.id, self.action_recset.id, [self.recordset.id]), + ) + + @mute_logger("[importer]") + def test_server_action_trigger_each(self): + self.recordset.server_action_ids += self.action_recset + self.recordset.server_action_trigger_on = "each_importer_done" + mocked_hook = mock.patch(LISTENER_PATH + "._add_after_commit_hook") + with mocked_hook as mocked: + self.record.run_import() + self.assertEqual(mocked.call_count, 2) + + @staticmethod + def _mocked_get_eval_context(self, orig_meth, action=None): + global MOCKED_LOG_ENTRIES + res = orig_meth(action) + res["log"] = lambda x: MOCKED_LOG_ENTRIES.append(x) + return res + + @mute_logger("[importer]") + def test_server_action_call_from_hook(self): + global MOCKED_LOG_ENTRIES + listener = WorkContext( + components_registry=self.comp_registry, + collection=self.backend, + model_name="import.recordset", + ).component_by_name("recordset.event.listener") + record_ids = self.env["res.partner"].search([], limit=10).ids + action = self.action_partner + # When mocking the ctx is not preserved as we pass the action straight. + # Hence, we must replicate the same ctx that will be passed by the listener. + action = action.with_context( + **listener._run_server_action_ctx(self.recordset.id, action.id, record_ids) + ) + orig_meth = action._get_eval_context + mock_eval_ctx = mock.patch.object( + type(self.env["ir.actions.server"]), + "_get_eval_context", + wraps=lambda x: self._mocked_get_eval_context(x, orig_meth, action=action), + spec=True, + ) + with mock_eval_ctx: + listener._run_server_action(self.recordset.id, action.id, record_ids) + self.assertEqual( + MOCKED_LOG_ENTRIES[0], + f"Exec for recordset: {self.recordset.id}. Partners: {str(record_ids)}", + ) + + def test_post_commit_hook_registration(self): + listener = WorkContext( + components_registry=self.comp_registry, + collection=self.backend, + model_name="import.recordset", + ).component_by_name("recordset.event.listener") + listener._add_after_commit_hook( + self.recordset.id, self.action_partner.id, [1, 2, 3] + ) + callback = self.env.cr.postcommit._funcs.pop() + self.assertEqual(callback.func.__name__, "_run_server_action_post_commit") + self.assertEqual( + callback.args, (self.recordset.id, self.action_partner.id, [1, 2, 3]) + ) diff --git a/connector_importer/tests/test_recordset.py b/connector_importer/tests/test_recordset.py index 71e4bc1f2..df3e6b823 100644 --- a/connector_importer/tests/test_recordset.py +++ b/connector_importer/tests/test_recordset.py @@ -90,3 +90,22 @@ def test_get_report_html_data(self): key = list(by_model.keys())[0] self.assertEqual(key._name, "ir.model") self.assertEqual(key.model, "res.partner") + + def test_importable_models(self): + self.itype.write( + { + "options": """ +- model: res.partner + importer: partner.importer +- model: res.partner.category +- model: res.lang + """ + } + ) + expected = ("res.partner", "res.lang", "res.partner.category") + models = self.recordset.importable_model_ids.mapped("model") + for model in expected: + self.assertIn(model, models) + models = self.recordset.server_action_importable_model_ids.mapped("model") + for model in expected + ("import.recordset",): + self.assertIn(model, models) diff --git a/connector_importer/views/recordset_views.xml b/connector_importer/views/recordset_views.xml index 449be7033..23af277c7 100644 --- a/connector_importer/views/recordset_views.xml +++ b/connector_importer/views/recordset_views.xml @@ -75,6 +75,31 @@ + + + + + + + + + + + + + From fd72de693b70de04cf86be2c6d65ab61104efefc Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 19 Jun 2023 16:38:21 +0200 Subject: [PATCH 13/16] c_importer: allow ctx key via options You can now pass any context key to the importer (propagated to the record handler) by using 'ctx' key in the 'importer' options. --- connector_importer/components/importer.py | 5 +++-- .../tests/test_record_importer_basic.py | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/connector_importer/components/importer.py b/connector_importer/components/importer.py index e2c99326a..f46939df9 100644 --- a/connector_importer/components/importer.py +++ b/connector_importer/components/importer.py @@ -295,13 +295,14 @@ def _load_mapper_options(self): """Retrieve mapper options.""" return {"override_existing": self.must_override_existing} - # TODO: make these contexts customizable via recordset settings def _odoo_default_context(self): """Default context to be used in both create and write methods""" - return { + ctx = { "importer_type_id": self.recordset.import_type_id.id, "tracking_disable": True, } + ctx.update(self.work.options.importer.get("ctx", {})) + return ctx def _odoo_create_context(self): """Inject context variables on create, merged by odoorecord handler.""" diff --git a/connector_importer/tests/test_record_importer_basic.py b/connector_importer/tests/test_record_importer_basic.py index b106e86d9..54d0f7242 100644 --- a/connector_importer/tests/test_record_importer_basic.py +++ b/connector_importer/tests/test_record_importer_basic.py @@ -19,11 +19,12 @@ def _get_components(self): return [PartnerRecordImporter, PartnerMapper] - def _get_importer(self): + def _get_importer(self, options=None): + options = options or {"importer": {}, "mapper": {}} with self.backend.work_on( self.record._name, components_registry=self.comp_registry, - options=DotDict({"importer": {}, "mapper": {}}), + options=DotDict(options), ) as work: return work.component(usage="record.importer", model_name="res.partner") @@ -104,3 +105,18 @@ def test_importer_get_mapper(self): importer._mapper_name = "fake.partner.mapper" mapper = importer._get_mapper() self.assertEqual(mapper._name, "fake.partner.mapper") + + def test_importer_context(self): + importer = self._get_importer( + options={"importer": {"ctx": {"key1": 1, "key2": 2}}, "mapper": {}} + ) + importer._init_importer(self.recordset) + self.assertEqual( + importer._odoo_create_context(), + { + "importer_type_id": self.recordset.import_type_id.id, + "tracking_disable": True, + "key1": 1, + "key2": 2, + }, + ) From 8c2e59478844f0cf5036d941d2c77d499e6ca68f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 20 Jun 2023 12:07:06 +0200 Subject: [PATCH 14/16] connector_importer: fix import.type copy --- connector_importer/models/import_type.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/connector_importer/models/import_type.py b/connector_importer/models/import_type.py index 5912bc0e7..0d9bb851a 100644 --- a/connector_importer/models/import_type.py +++ b/connector_importer/models/import_type.py @@ -63,7 +63,7 @@ class ImportType(models.Model): name = fields.Char(required=True, help="A meaningful human-friendly name") description = fields.Text() - key = fields.Char(required=True, help="Unique mnemonic identifier") + key = fields.Char(required=True, help="Unique mnemonic identifier", copy=False) options = fields.Text(help="YAML configuration") settings = fields.Text( string="Legacy Settings", @@ -167,3 +167,9 @@ def available_models(self): if _line == lines[-1]: is_last_importer = True yield (model_name.strip(), importer.strip(), is_last_importer) + + def copy_data(self, default=None): + res = super().copy_data(default) + for data, rec in zip(res, self): + data["key"] = rec.key + "_COPY_FIXME" + return res From 3d9bec092f0b0cf223771f38108d2ce5f4584856 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Jul 2023 11:43:29 +0200 Subject: [PATCH 15/16] Make pre-commit happy --- connector_importer/tests/test_mapper.py | 2 +- connector_importer/tests/test_record_handler.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/connector_importer/tests/test_mapper.py b/connector_importer/tests/test_mapper.py index 1a82a00a7..7065c5f7f 100644 --- a/connector_importer/tests/test_mapper.py +++ b/connector_importer/tests/test_mapper.py @@ -7,7 +7,7 @@ try: from odoo.tests.common import RecordCapturer except ImportError: - from odoo.addons.connector_importer.tests.record_capturer import RecordCapturer + from .record_capturer import RecordCapturer from .common import TestImporterBase diff --git a/connector_importer/tests/test_record_handler.py b/connector_importer/tests/test_record_handler.py index e35f7824b..6651fc143 100644 --- a/connector_importer/tests/test_record_handler.py +++ b/connector_importer/tests/test_record_handler.py @@ -18,7 +18,7 @@ class TestRecordImporter(TestImporterBase): @classmethod - def _setup_records(cls): + def _setup_records(cls): # pylint: disable=missing-return super()._setup_records() cls.record = cls.env["import.record"].create({"recordset_id": cls.recordset.id}) From a3d0fda9be35a71baebcf2246cd8477714a4e67f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Jul 2023 11:02:38 +0200 Subject: [PATCH 16/16] c_importer: fix recordset direct creation Was broken because available_importers requires an import type which is not available before editing. It was also impossible to set the backend directly. --- connector_importer/models/recordset.py | 2 +- connector_importer/views/backend_views.xml | 6 +++++- connector_importer/views/recordset_views.xml | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/connector_importer/models/recordset.py b/connector_importer/models/recordset.py index 3c920f408..c6e4e456c 100644 --- a/connector_importer/models/recordset.py +++ b/connector_importer/models/recordset.py @@ -274,7 +274,7 @@ def _get_global_state(self): return res def available_importers(self): - return self.import_type_id.available_importers() + return self.import_type_id.available_importers() if self.import_type_id else () def import_recordset(self): """This job will import a recordset.""" diff --git a/connector_importer/views/backend_views.xml b/connector_importer/views/backend_views.xml index 66e50f6d6..9dc222f99 100644 --- a/connector_importer/views/backend_views.xml +++ b/connector_importer/views/backend_views.xml @@ -74,7 +74,11 @@ - + diff --git a/connector_importer/views/recordset_views.xml b/connector_importer/views/recordset_views.xml index 23af277c7..5f6f35cd5 100644 --- a/connector_importer/views/recordset_views.xml +++ b/connector_importer/views/recordset_views.xml @@ -9,6 +9,12 @@ +