From eaaaac1951955a4f622abb27c9e2c6445d1b73f3 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Thu, 22 Jun 2017 12:09:47 +0200 Subject: [PATCH] [10.0][MIG][base_import_match] Migration and update Includes: - Normal migration steps. - Usage of brand new `_inherit = "base"` in Odoo 10, which implies removing a lot of monkey-patching code. - Log a warning when multiple matches are found. --- base_import_match/README.rst | 4 +- .../{__openerp__.py => __manifest__.py} | 2 +- base_import_match/models/__init__.py | 1 + base_import_match/models/base.py | 54 +++++ base_import_match/models/base_import.py | 198 ++++-------------- base_import_match/tests/test_import.py | 2 +- 6 files changed, 96 insertions(+), 165 deletions(-) rename base_import_match/{__openerp__.py => __manifest__.py} (96%) create mode 100644 base_import_match/models/base.py diff --git a/base_import_match/README.rst b/base_import_match/README.rst index 5b9716907..c11ff80bb 100644 --- a/base_import_match/README.rst +++ b/base_import_match/README.rst @@ -61,7 +61,7 @@ To configure this module, you need to: #. Go to *Settings > Technical > Database Structure > Import Match*. #. *Create*. #. Choose a *Model*. -#. Choose the *Fields* that conform an unique key in that model. +#. Choose the *Fields* that conform a unique key in that model. #. If the rule must be used only for certain imported values, check *Conditional* and enter the **exact string** that is going to be imported in *Imported value*. @@ -84,7 +84,7 @@ To use this module, you need to: .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas :alt: Try me on Runbot - :target: https://runbot.odoo-community.org/runbot/149/9.0 + :target: https://runbot.odoo-community.org/runbot/149/10.0 Known Issues / Roadmap ====================== diff --git a/base_import_match/__openerp__.py b/base_import_match/__manifest__.py similarity index 96% rename from base_import_match/__openerp__.py rename to base_import_match/__manifest__.py index a3564a891..a9068280b 100644 --- a/base_import_match/__openerp__.py +++ b/base_import_match/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Base Import Match", "summary": "Try to avoid duplicates before importing", - "version": "9.0.1.0.0", + "version": "10.0.1.0.0", "category": "Tools", "website": "https://tecnativa.com", "author": "Grupo ESOC IngenierĂ­a de Servicios," diff --git a/base_import_match/models/__init__.py b/base_import_match/models/__init__.py index 2e3b69da7..45ac4f350 100644 --- a/base_import_match/models/__init__.py +++ b/base_import_match/models/__init__.py @@ -3,4 +3,5 @@ # Copyright 2016 Tecnativa - Vicent Cubells # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import base from . import base_import diff --git a/base_import_match/models/base.py b/base_import_match/models/base.py new file mode 100644 index 000000000..62dff40bf --- /dev/null +++ b/base_import_match/models/base.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Jairo Llopis +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, models + + +class Base(models.AbstractModel): + _inherit = "base" + + @api.model + def load(self, fields, data): + """Try to identify rows by other pseudo-unique keys. + + It searches for rows that have no XMLID specified, and gives them + one if any :attr:`~.field_ids` combination is found. With a valid + XMLID in place, Odoo will understand that it must *update* the + record instead of *creating* a new one. + """ + # We only need to patch this call if there are usable rules for it + if self.env["base_import.match"]._usable_rules(self._name, fields): + newdata = list() + # Data conversion to ORM format + import_fields = map(models.fix_import_export_id_paths, fields) + converted_data = self._convert_records( + self._extract_records(import_fields, data)) + # Mock Odoo to believe the user is importing the ID field + if "id" not in fields: + fields.append("id") + import_fields.append(["id"]) + # Needed to match with converted data field names + clean_fields = [f[0] for f in import_fields] + for dbid, xmlid, record, info in converted_data: + row = dict(zip(clean_fields, data[info["record"]])) + match = self + if xmlid: + # Skip rows with ID, they do not need all this + row["id"] = xmlid + continue + elif dbid: + # Find the xmlid for this dbid + match = self.browse(dbid) + else: + # Store records that match a combination + match = self.env["base_import.match"]._match_find( + self, record, row) + # Give a valid XMLID to this row if a match was found + row["id"] = (match._BaseModel__export_xml_id() + if match else row.get("id", u"")) + # Store the modified row, in the same order as fields + newdata.append(tuple(row[f] for f in clean_fields)) + # We will import the patched data to get updates on matches + data = newdata + # Normal method handles the rest of the job + return super(Base, self).load(fields, data) diff --git a/base_import_match/models/base_import.py b/base_import_match/models/base_import.py index 5c7d57384..9350ce5ab 100644 --- a/base_import_match/models/base_import.py +++ b/base_import_match/models/base_import.py @@ -2,8 +2,10 @@ # Copyright 2016 Grupo ESOC IngenierĂ­a de Servicios, S.L.U. - Jairo Llopis # Copyright 2016 Tecnativa - Vicent Cubells # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from openerp import api, fields, models -from openerp import SUPERUSER_ID # TODO remove in v10 +import logging +from odoo import api, fields, models, tools + +_logger = logging.getLogger(__name__) class BaseImportMatch(models.Model): @@ -21,7 +23,7 @@ class BaseImportMatch(models.Model): "Model", required=True, ondelete="cascade", - domain=[("transient ", "=", False)], + domain=[("transient", "=", False)], help="In this model you will apply the match.") model_name = fields.Char( related="model_id.model", @@ -34,63 +36,18 @@ class BaseImportMatch(models.Model): required=True, help="Fields that will define an unique key.") - @api.multi @api.onchange("model_id") def _onchange_model_id(self): - self.field_ids.unlink() - - @api.model - def create(self, vals): - """Wrap the model after creation.""" - result = super(BaseImportMatch, self).create(vals) - self._load_autopatch(result.model_name) - return result - - @api.multi - def unlink(self): - """Unwrap the model after deletion.""" - models = set(self.mapped("model_name")) - result = super(BaseImportMatch, self).unlink() - for model in models: - self._load_autopatch(model) - return result + self.field_ids = False - @api.multi - def write(self, vals): - """Wrap the model after writing.""" - result = super(BaseImportMatch, self).write(vals) - - if "model_id" in vals or "model_name" in vals: - for s in self: - self._load_autopatch(s.model_name) - - return result - - # TODO convert to @api.model_cr in v10 - def _register_hook(self, cr): - """Autopatch on init.""" - models = set( - self.browse( - cr, - SUPERUSER_ID, - self.search(cr, SUPERUSER_ID, list())) - .mapped("model_name")) - for model in models: - self._load_autopatch(cr, SUPERUSER_ID, model) - - @api.multi @api.depends("model_id", "field_ids") def _compute_name(self): """Automatic self-descriptive name for the setting records.""" - for s in self: - s.name = u"{}: {}".format( - s.model_id.display_name, - " + ".join( - s.field_ids.mapped( - lambda r: ( - (u"{} ({})" if r.conditional else u"{}").format( - r.field_id.name, - r.imported_value))))) + for one in self: + one.name = u"{}: {}".format( + one.model_id.display_name, + " + ".join(one.field_ids.mapped("display_name")), + ) @api.model def _match_find(self, model, converted_row, imported_row): @@ -100,12 +57,12 @@ class BaseImportMatch(models.Model): imported data, and return a match for the first rule that returns a single result. - :param openerp.models.Model model: + :param odoo.models.Model model: Model object that is being imported. :param dict converted_row: Row converted to Odoo api format, like the 3rd value that - :meth:`openerp.models.Model._convert_records` returns. + :meth:`odoo.models.Model._convert_records` returns. :param dict imported_row: Row as it is being imported, in format:: @@ -116,18 +73,16 @@ class BaseImportMatch(models.Model): ... } - :return openerp.models.Model: + :return odoo.models.Model: Return a dataset with one single match if it was found, or an empty dataset if none or multiple matches were found. """ # Get usable rules to perform matches - usable = self._usable_for_load(model._name, converted_row.keys()) - + usable = self._usable_rules(model._name, converted_row) # Traverse usable combinations for combination in usable: combination_valid = True domain = list() - for field in combination.field_ids: # Check imported value if it is a conditional field if field.conditional: @@ -135,114 +90,26 @@ class BaseImportMatch(models.Model): if imported_row[field.name] != field.imported_value: combination_valid = False break - domain.append((field.name, "=", converted_row[field.name])) - if not combination_valid: continue - match = model.search(domain) - # When a single match is found, stop searching if len(match) == 1: return match - + elif match: + _logger.warning( + "Found multiple matches for model %s and domain %s; " + "falling back to default behavior (create new record)", + model._name, + domain, + ) # Return an empty match if none or multiple was found return model @api.model - def _load_wrapper(self): - """Create a new load patch method.""" - @api.model - def wrapper(self, fields, data): - """Try to identify rows by other pseudo-unique keys. - - It searches for rows that have no XMLID specified, and gives them - one if any :attr:`~.field_ids` combination is found. With a valid - XMLID in place, Odoo will understand that it must *update* the - record instead of *creating* a new one. - """ - newdata = list() - - # Data conversion to ORM format - import_fields = map(models.fix_import_export_id_paths, fields) - converted_data = self._convert_records( - self._extract_records(import_fields, data)) - - # Mock Odoo to believe the user is importing the ID field - if "id" not in fields: - fields.append("id") - import_fields.append(["id"]) - - # Needed to match with converted data field names - clean_fields = [f[0] for f in import_fields] - - for dbid, xmlid, record, info in converted_data: - row = dict(zip(clean_fields, data[info["record"]])) - match = self - - if xmlid: - # Skip rows with ID, they do not need all this - row["id"] = xmlid - elif dbid: - # Find the xmlid for this dbid - match = self.browse(dbid) - else: - # Store records that match a combination - match = self.env["base_import.match"]._match_find( - self, record, row) - - # Give a valid XMLID to this row if a match was found - row["id"] = (match._BaseModel__export_xml_id() - if match else row.get("id", u"")) - - # Store the modified row, in the same order as fields - newdata.append(tuple(row[f] for f in clean_fields)) - - # Leave the rest to Odoo itself - del data - return wrapper.origin(self, fields, newdata) - - # Flag to avoid confusions with other possible wrappers - wrapper.__base_import_match = True - - return wrapper - - @api.model - def _load_autopatch(self, model_name): - """[Un]apply patch automatically.""" - self._load_unpatch(model_name) - if self.search([("model_name", "=", model_name)]): - self._load_patch(model_name) - - @api.model - def _load_patch(self, model_name): - """Apply patch for :param:`model_name`'s load method. - - :param str model_name: - Model technical name, such as ``res.partner``. - """ - self.env[model_name]._patch_method( - "load", self._load_wrapper()) - - @api.model - def _load_unpatch(self, model_name): - """Apply patch for :param:`model_name`'s load method. - - :param str model_name: - Model technical name, such as ``res.partner``. - """ - model = self.env[model_name] - - # Unapply patch only if there is one - try: - if model.load.__base_import_match: - model._revert_method("load") - except AttributeError: - pass - - @api.model - def _usable_for_load(self, model_name, fields): + @tools.ormcache("model_name", "fields") + def _usable_rules(self, model_name, fields): """Return a set of elements usable for calling ``load()``. :param str model_name: @@ -251,15 +118,16 @@ class BaseImportMatch(models.Model): :param list(str|bool) fields: List of field names being imported. + + :return bool: + Indicates if we should patch its load method. """ result = self available = self.search([("model_name", "=", model_name)]) - # Use only criteria with all required fields to match for record in available: if all(f.name in fields for f in record.field_ids): - result += record - + result |= record return result @@ -291,7 +159,15 @@ class BaseImportMatchField(models.Model): "string, and comparison is case-sensitive so if you set 'True', " "it will NOT match '1' nor 'true', only EXACTLY 'True'.") - @api.multi + @api.depends("conditional", "field_id", "imported_value") + def _compute_display_name(self): + for one in self: + pattern = u"{name} ({cond})" if one.conditional else u"{name}" + one.display_name = pattern.format( + name=one.field_id.name, + cond=one.imported_value, + ) + @api.onchange("field_id", "match_id", "conditional", "imported_value") def _onchange_match_id_name(self): """Update match name.""" diff --git a/base_import_match/tests/test_import.py b/base_import_match/tests/test_import.py index 0eb432b6f..b7d977fa1 100644 --- a/base_import_match/tests/test_import.py +++ b/base_import_match/tests/test_import.py @@ -4,7 +4,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from os import path -from openerp.tests.common import TransactionCase +from odoo.tests.common import TransactionCase PATH = path.join(path.dirname(__file__), "import_data", "%s.csv")