From 97d59dd73bb25e13d999355564466ef16df551c6 Mon Sep 17 00:00:00 2001 From: Carlos Dauden Date: Thu, 23 Apr 2026 14:25:57 +0200 Subject: [PATCH] [ADD] base_import_match: New module to updating existing records by any combination of stored field values --- base_import_match/README.rst | 140 +++++ base_import_match/__init__.py | 4 + base_import_match/__manifest__.py | 22 + base_import_match/i18n/base_import_match.pot | 213 ++++++++ base_import_match/models/__init__.py | 3 + base_import_match/models/base_import_match.py | 101 ++++ base_import_match/pyproject.toml | 3 + base_import_match/readme/CONFIGURE.md | 12 + base_import_match/readme/CONTRIBUTORS.md | 2 + base_import_match/readme/DESCRIPTION.md | 18 + base_import_match/readme/USAGE.md | 28 + .../security/ir.model.access.csv | 3 + .../static/description/index.html | 484 ++++++++++++++++++ .../views/base_import_match_views.xml | 122 +++++ base_import_match/views/menu.xml | 18 + base_import_match/wizards/__init__.py | 3 + base_import_match/wizards/base_import.py | 239 +++++++++ 17 files changed, 1415 insertions(+) create mode 100644 base_import_match/README.rst create mode 100644 base_import_match/__init__.py create mode 100644 base_import_match/__manifest__.py create mode 100644 base_import_match/i18n/base_import_match.pot create mode 100644 base_import_match/models/__init__.py create mode 100644 base_import_match/models/base_import_match.py create mode 100644 base_import_match/pyproject.toml create mode 100644 base_import_match/readme/CONFIGURE.md create mode 100644 base_import_match/readme/CONTRIBUTORS.md create mode 100644 base_import_match/readme/DESCRIPTION.md create mode 100644 base_import_match/readme/USAGE.md create mode 100644 base_import_match/security/ir.model.access.csv create mode 100644 base_import_match/static/description/index.html create mode 100644 base_import_match/views/base_import_match_views.xml create mode 100644 base_import_match/views/menu.xml create mode 100644 base_import_match/wizards/__init__.py create mode 100644 base_import_match/wizards/base_import.py diff --git a/base_import_match/README.rst b/base_import_match/README.rst new file mode 100644 index 00000000000..ec0a03b19ee --- /dev/null +++ b/base_import_match/README.rst @@ -0,0 +1,140 @@ +====================== +Import Match by Fields +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1aa689e0bf3e1bd6437f5b19cc2e0eb4b9a4d76fcfd4c0f7f71e65fa385c1c08 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge_devstat| image:: https://img.shields.io/badge/maturity-beta-brightgreen.png + :target: https://odoo-community.org/page/development-status + :alt: Beta + +.. |badge_license| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :alt: AGPL-3 + +|badge_devstat| |badge_license| + +This module extends Odoo's standard import wizard to allow identifying +and updating existing records by any combination of stored field values, +without requiring the External ID (``id``) column that changes across +databases. + +Problem +------- + +In a standard Odoo import, updating an existing record requires +including its **External ID** (``id``) or its **Database ID** (``.id``). +Both values change between instances (production, staging, development, +…), making it hard to prepare reusable import files. + +Solution +-------- + +Configure one or more *match configurations* that tell the module which +field(s) to use as a lookup key. At import time the module searches the +database using those fields; if a single matching record is found it is +updated, otherwise a new record is created. + +Multiple fields can be combined with AND semantics. Many2one fields are +resolved automatically by display name. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +1. Go to **Settings → Import → Import Match Configurations**. + +2. Create a new configuration: + + - **Name** – a free label (e.g. + ``E-commerce categories by name + seo_name``). + - **Model** – the Odoo model to target (e.g. + ``product.public.category``). + - **Match Fields** – one or more stored fields to match on (e.g. + ``name`` and ``seo_name``). + +3. Save the configuration. + +Multiple configurations can exist for the same model. The **Priority** +(sequence) field controls which is tried first; the first one that +returns a unique match is applied. + +Usage +===== + +Prepare your CSV or XLSX file using only business columns — no External +ID column required. + +**Example** — update e-commerce category descriptions matching by name +**and** seo_name: + +.. code:: text + + name,seo_name,description + Smartphones,Electronics,All our smartphone models + Laptops,Electronics,Premium laptop range + +When importing into ``product.public.category``: + +- **Unique match** (``name`` + ``seo_name`` → one record found): the + existing record is updated. +- **No match**: a new record is created. +- **Multiple matches**: the row is skipped and a warning is written to + the server log to avoid ambiguous writes. + +.. + + **Note:** If your file already contains an ``id`` or ``.id`` column, + this module deactivates itself for that import and Odoo's standard + behaviour applies. + +Many2one resolution +~~~~~~~~~~~~~~~~~~~ + +For many2one fields (e.g. ``website_id``), the raw string value from the +import file is resolved to a database ID via an exact ``name_search``. +If zero or more than one related record matches the display name, the +row is treated as a new record (no update is performed) and a message is +written to the log. + +Credits +======= + +Authors +------- + +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__: + + - Carlos Dauden + +Maintainers +----------- + +This module is maintained by Tecnativa. + +Contact the maintainer through their official support channels in case you find +any issues with this module. + + + +.. |maintainer-carlosdauden| image:: https://github.com/carlosdauden.png?size=40px + :target: https://github.com/carlosdauden + :alt: carlosdauden + +Current maintainer: + +|maintainer-carlosdauden| diff --git a/base_import_match/__init__.py b/base_import_match/__init__.py new file mode 100644 index 00000000000..23e880e1a5f --- /dev/null +++ b/base_import_match/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0-or-later (https://www.gnu.org/licenses/agpl). +from . import models +from . import wizards diff --git a/base_import_match/__manifest__.py b/base_import_match/__manifest__.py new file mode 100644 index 00000000000..0db6a37c085 --- /dev/null +++ b/base_import_match/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0-or-later (https://www.gnu.org/licenses/agpl). +{ + "name": "Import Match by Fields", + "summary": ( + "Update existing records on import by matching any stored field(s), " + "without requiring the External ID." + ), + "version": "18.0.1.0.0", + "category": "Tools", + "website": "https://github.com/OCA/server-tools", + "author": "Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["carlosdauden"], + "license": "AGPL-3", + "depends": ["base_import"], + "data": [ + "security/ir.model.access.csv", + "views/base_import_match_views.xml", + "views/menu.xml", + ], + "installable": True, +} diff --git a/base_import_match/i18n/base_import_match.pot b/base_import_match/i18n/base_import_match.pot new file mode 100644 index 00000000000..cafa2a61805 --- /dev/null +++ b/base_import_match/i18n/base_import_match.pot @@ -0,0 +1,213 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_import_match +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-23 12:17+0000\n" +"PO-Revision-Date: 2026-04-23 12:17+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_import_match +#: model_terms:ir.actions.act_window,help:base_import_match.action_base_import_match +msgid "" +"Example: to update e-commerce category\n" +" descriptions, create a configuration for\n" +" product.public.category using fields\n" +" name and seo_name." +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "How it works" +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "" +"Multiple matches — the row is\n" +" skipped and a warning is written to the server\n" +" log to avoid ambiguous updates." +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "" +"No match — a new record is\n" +" created." +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "" +"Unique match — the existing\n" +" record is updated with the imported values." +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__active +msgid "Active" +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "" +"All selected fields must match simultaneously\n" +" (AND). For many2one fields, the\n" +" related record is resolved by its display\n" +" name." +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "Archived" +msgstr "" + +#. module: base_import_match +#. odoo-python +#: code:addons/base_import_match/models/base_import_match.py:0 +msgid "At least one match field must be selected in configuration '%(name)s'." +msgstr "" + +#. module: base_import_match +#: model:ir.model,name:base_import_match.model_base_import_import +msgid "Base Import" +msgstr "" + +#. module: base_import_match +#: model_terms:ir.actions.act_window,help:base_import_match.action_base_import_match +msgid "" +"Configure which fields to use to identify existing records\n" +" during import, instead of relying on the External ID." +msgstr "" + +#. module: base_import_match +#: model_terms:ir.actions.act_window,help:base_import_match.action_base_import_match +msgid "Create a new import match configuration" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__create_uid +msgid "Created by" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__create_date +msgid "Created on" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__display_name +msgid "Display Name" +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "" +"Do not include an\n" +" id (External ID) or\n" +" .id (Database ID) column in your\n" +" import file; the module resolves those\n" +" automatically." +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,help:base_import_match.field_base_import_match__sequence +msgid "" +"Evaluation order when several configurations exist for the same model. The " +"first one that returns a unique record is applied." +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,help:base_import_match.field_base_import_match__field_ids +msgid "" +"Fields used to look up existing records. All selected fields must match " +"simultaneously (AND). For many2one fields the related record is resolved by " +"display name." +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,help:base_import_match.field_base_import_match__name +msgid "Human-readable label for this match configuration." +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__id +msgid "ID" +msgstr "" + +#. module: base_import_match +#: model:ir.ui.menu,name:base_import_match.menu_base_import_match_root +msgid "Import" +msgstr "" + +#. module: base_import_match +#: model:ir.model,name:base_import_match.model_base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "Import Match Configuration" +msgstr "" + +#. module: base_import_match +#: model:ir.actions.act_window,name:base_import_match.action_base_import_match +#: model:ir.ui.menu,name:base_import_match.menu_base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_list +msgid "Import Match Configurations" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__write_date +msgid "Last Updated on" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__field_ids +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "Match Fields" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__model_id +msgid "Model" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__name +msgid "Name" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,help:base_import_match.field_base_import_match__model_id +msgid "Odoo model to which this configuration applies." +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__sequence +msgid "Priority" +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "Settings" +msgstr "" + +#. module: base_import_match +#: model:ir.model.fields,field_description:base_import_match.field_base_import_match__model_name +msgid "Technical Model Name" +msgstr "" + +#. module: base_import_match +#: model_terms:ir.ui.view,arch_db:base_import_match.view_base_import_match_form +msgid "e.g. E-commerce categories by name + seo_name" +msgstr "" diff --git a/base_import_match/models/__init__.py b/base_import_match/models/__init__.py new file mode 100644 index 00000000000..66c01fbcdbb --- /dev/null +++ b/base_import_match/models/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0-or-later (https://www.gnu.org/licenses/agpl). +from . import base_import_match diff --git a/base_import_match/models/base_import_match.py b/base_import_match/models/base_import_match.py new file mode 100644 index 00000000000..53d68c0303a --- /dev/null +++ b/base_import_match/models/base_import_match.py @@ -0,0 +1,101 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0-or-later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.translate import _ + + +class BaseImportMatch(models.Model): + """Match configuration for record import. + + Defines which fields to use as lookup keys when importing records, + instead of relying on External IDs that differ across databases. + + Multiple fields can be combined: all of them must match simultaneously + (AND condition). Configurations are evaluated in *sequence* order; the + first one that finds a unique record wins. + + Example — update e-commerce category descriptions by name + seo_name: + Model : product.public.category + Fields : name, seo_name + """ + + _name = "base_import.match" + _description = "Import Match Configuration" + _order = "sequence, id" + _rec_name = "name" + + name = fields.Char( + required=True, + translate=True, + help="Human-readable label for this match configuration.", + ) + sequence = fields.Integer( + string="Priority", + default=10, + help=( + "Evaluation order when several configurations exist for the same " + "model. The first one that returns a unique record is applied." + ), + ) + active = fields.Boolean( + default=True, + ) + model_id = fields.Many2one( + comodel_name="ir.model", + string="Model", + required=True, + ondelete="cascade", + domain=[("transient", "=", False)], + help="Odoo model to which this configuration applies.", + ) + model_name = fields.Char( + related="model_id.model", + store=True, + index=True, + string="Technical Model Name", + ) + field_ids = fields.Many2many( + comodel_name="ir.model.fields", + relation="base_import_match_field_rel", + column1="match_id", + column2="field_id", + string="Match Fields", + domain=( + "[('model_id', '=', model_id)," + " ('ttype', 'not in'," + " ['one2many', 'many2many', 'binary', 'html'])," + " ('store', '=', True)]" + ), + help=( + "Fields used to look up existing records. All selected fields " + "must match simultaneously (AND). For many2one fields the " + "related record is resolved by display name." + ), + ) + + # ------------------------------------------------------------------ + # Constraints + # ------------------------------------------------------------------ + + @api.constrains("field_ids") + def _check_field_ids(self): + for record in self: + if not record.field_ids: + raise ValidationError( + _( + "At least one match field must be selected " + "in configuration '%(name)s'.", + name=record.name, + ) + ) + + # ------------------------------------------------------------------ + # Onchange helpers + # ------------------------------------------------------------------ + + @api.onchange("model_id") + def _onchange_model_id(self): + """Clear field selection when the model changes.""" + self.field_ids = [Command.clear()] diff --git a/base_import_match/pyproject.toml b/base_import_match/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/base_import_match/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_import_match/readme/CONFIGURE.md b/base_import_match/readme/CONFIGURE.md new file mode 100644 index 00000000000..02773b3d9e5 --- /dev/null +++ b/base_import_match/readme/CONFIGURE.md @@ -0,0 +1,12 @@ +1. Go to **Settings → Import → Import Match Configurations**. +2. Create a new configuration: + + - **Name** – a free label (e.g. `E-commerce categories by name + seo_name`). + - **Model** – the Odoo model to target (e.g. `product.public.category`). + - **Match Fields** – one or more stored fields to match on (e.g. `name` and + `seo_name`). + +3. Save the configuration. + +Multiple configurations can exist for the same model. The **Priority** (sequence) field +controls which is tried first; the first one that returns a unique match is applied. diff --git a/base_import_match/readme/CONTRIBUTORS.md b/base_import_match/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..37d0fcf44b6 --- /dev/null +++ b/base_import_match/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://www.tecnativa.com): + - Carlos Dauden diff --git a/base_import_match/readme/DESCRIPTION.md b/base_import_match/readme/DESCRIPTION.md new file mode 100644 index 00000000000..66c3ce4e547 --- /dev/null +++ b/base_import_match/readme/DESCRIPTION.md @@ -0,0 +1,18 @@ +This module extends Odoo's standard import wizard to allow identifying and updating +existing records by any combination of stored field values, without requiring the +External ID (`id`) column that changes across databases. + +## Problem + +In a standard Odoo import, updating an existing record requires including its **External +ID** (`id`) or its **Database ID** (`.id`). Both values change between instances +(production, staging, development, …), making it hard to prepare reusable import files. + +## Solution + +Configure one or more _match configurations_ that tell the module which field(s) to use +as a lookup key. At import time the module searches the database using those fields; if +a single matching record is found it is updated, otherwise a new record is created. + +Multiple fields can be combined with AND semantics. Many2one fields are resolved +automatically by display name. diff --git a/base_import_match/readme/USAGE.md b/base_import_match/readme/USAGE.md new file mode 100644 index 00000000000..64a5a3be6c3 --- /dev/null +++ b/base_import_match/readme/USAGE.md @@ -0,0 +1,28 @@ +Prepare your CSV or XLSX file using only business columns — no External ID column +required. + +**Example** — update e-commerce category descriptions matching by name **and** seo_name: + +```text +name,seo_name,description +Smartphones,Electronics,All our smartphone models +Laptops,Electronics,Premium laptop range +``` + +When importing into `product.public.category`: + +- **Unique match** (`name` + `seo_name` → one record found): the existing record is + updated. +- **No match**: a new record is created. +- **Multiple matches**: the row is skipped and a warning is written to the server log to + avoid ambiguous writes. + +> **Note:** If your file already contains an `id` or `.id` column, this module +> deactivates itself for that import and Odoo's standard behaviour applies. + +### Many2one resolution + +For many2one fields (e.g. `website_id`), the raw string value from the import file is +resolved to a database ID via an exact `name_search`. If zero or more than one related +record matches the display name, the row is treated as a new record (no update is +performed) and a message is written to the log. diff --git a/base_import_match/security/ir.model.access.csv b/base_import_match/security/ir.model.access.csv new file mode 100644 index 00000000000..bf6eb9dbfbc --- /dev/null +++ b/base_import_match/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_base_import_match_system,base_import.match (system),model_base_import_match,base.group_system,1,1,1,1 +access_base_import_match_user,base_import.match (user),model_base_import_match,base.group_user,1,0,0,0 diff --git a/base_import_match/static/description/index.html b/base_import_match/static/description/index.html new file mode 100644 index 00000000000..c019bcd9f55 --- /dev/null +++ b/base_import_match/static/description/index.html @@ -0,0 +1,484 @@ + + + + + +Import Match by Fields + + + +
+

Import Match by Fields

+ + +

Beta AGPL-3

+

This module extends Odoo’s standard import wizard to allow identifying +and updating existing records by any combination of stored field values, +without requiring the External ID (id) column that changes across +databases.

+
+

Problem

+

In a standard Odoo import, updating an existing record requires +including its External ID (id) or its Database ID (.id). +Both values change between instances (production, staging, development, +…), making it hard to prepare reusable import files.

+
+
+

Solution

+

Configure one or more match configurations that tell the module which +field(s) to use as a lookup key. At import time the module searches the +database using those fields; if a single matching record is found it is +updated, otherwise a new record is created.

+

Multiple fields can be combined with AND semantics. Many2one fields are +resolved automatically by display name.

+

Table of contents

+ +
+

Configuration

+
    +
  1. Go to Settings → Import → Import Match Configurations.
  2. +
  3. Create a new configuration:
      +
    • Name – a free label (e.g. +E-commerce categories by name + seo_name).
    • +
    • Model – the Odoo model to target (e.g. +product.public.category).
    • +
    • Match Fields – one or more stored fields to match on (e.g. +name and seo_name).
    • +
    +
  4. +
  5. Save the configuration.
  6. +
+

Multiple configurations can exist for the same model. The Priority +(sequence) field controls which is tried first; the first one that +returns a unique match is applied.

+
+
+

Usage

+

Prepare your CSV or XLSX file using only business columns — no External +ID column required.

+

Example — update e-commerce category descriptions matching by name +and seo_name:

+
+name,seo_name,description
+Smartphones,Electronics,All our smartphone models
+Laptops,Electronics,Premium laptop range
+
+

When importing into product.public.category:

+
    +
  • Unique match (name + seo_name → one record found): the +existing record is updated.
  • +
  • No match: a new record is created.
  • +
  • Multiple matches: the row is skipped and a warning is written to +the server log to avoid ambiguous writes.
  • +
+ +
+Note: If your file already contains an id or .id column, +this module deactivates itself for that import and Odoo’s standard +behaviour applies.
+
+

Many2one resolution

+

For many2one fields (e.g. website_id), the raw string value from the +import file is resolved to a database ID via an exact name_search. +If zero or more than one related record matches the display name, the +row is treated as a new record (no update is performed) and a message is +written to the log.

+
+
+ +
+
+

Authors

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by Tecnativa.

+

Contact the maintainer through their official support channels in case you find +any issues with this module.

+

Current maintainer:

+

carlosdauden

+
+
+ + diff --git a/base_import_match/views/base_import_match_views.xml b/base_import_match/views/base_import_match_views.xml new file mode 100644 index 00000000000..705b3ce886c --- /dev/null +++ b/base_import_match/views/base_import_match_views.xml @@ -0,0 +1,122 @@ + + + + + base.import.match.list + base_import.match + + + + + + + + + + + + + + base.import.match.form + base_import.match + +
+ + +
+

+ +

+
+ + + + + + + + + + +

+ All selected fields must match simultaneously + (AND). For many2one fields, the + related record is resolved by its display + name. +

+
+
+ + +
+
+
+
+ + + + Import Match Configurations + base_import.match + list,form + {"active_test": False} + +

+ Create a new import match configuration +

+

+ Configure which fields to use to identify existing records + during import, instead of relying on the External ID. +

+

+ Example: to update e-commerce category + descriptions, create a configuration for + product.public.category using fields + name and seo_name. +

+
+
+
diff --git a/base_import_match/views/menu.xml b/base_import_match/views/menu.xml new file mode 100644 index 00000000000..1053b7ce0bd --- /dev/null +++ b/base_import_match/views/menu.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/base_import_match/wizards/__init__.py b/base_import_match/wizards/__init__.py new file mode 100644 index 00000000000..5f3331b6ad1 --- /dev/null +++ b/base_import_match/wizards/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0-or-later (https://www.gnu.org/licenses/agpl). +from . import base_import diff --git a/base_import_match/wizards/base_import.py b/base_import_match/wizards/base_import.py new file mode 100644 index 00000000000..4f825aa752e --- /dev/null +++ b/base_import_match/wizards/base_import.py @@ -0,0 +1,239 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0-or-later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class BaseImport(models.TransientModel): + _inherit = "base_import.import" + + @api.model + def _convert_import_data(self, fields, options): + """Extend the standard conversion to inject resolved database IDs. + + After the normal parse step, active ``base_import.match`` + configurations for the current model are evaluated. When at least + one row gets a match the ``.id`` column is prepended to both the + field list and every data row, causing ``load()`` to perform updates + instead of inserts for matched records. + + If the import file already contains an ``id`` or ``.id`` column, or + no active configuration exists for the model, the result of + ``super()`` is returned unchanged. + + :returns: (data, import_fields) — same contract as the parent method + :rtype: (list(list(str)), list(str)) + """ + data, import_fields = super()._convert_import_data(fields, options) + + if ".id" in import_fields or "id" in import_fields: + _logger.debug( + "base_import_match: ID column already present for model %s;" + " skipping automatic field matching.", + self.res_model, + ) + return data, import_fields + + match_configs = self.env["base_import.match"].search( + [("model_name", "=", self.res_model)] + ) + if not match_configs: + return data, import_fields + + data, import_fields = self._apply_field_matching( + data, import_fields, match_configs + ) + return data, import_fields + + # ------------------------------------------------------------------ + # Matching helpers + # ------------------------------------------------------------------ + + def _apply_field_matching(self, data, import_fields, match_configs): + """Iterate over every import row and inject the resolved database ID. + + For each row: + * Unique match found → prepend its database ID (update). + * No match found → prepend empty string (create). + * Multiple matches → skip row entirely (no create, no update). + + Returns the original objects unchanged when **no** row matched or + was skipped, avoiding an unnecessary ``.id`` column in the load call. + + :param data: parsed rows as returned by ``_convert_import_data`` + :type data: list(list(str)) + :param import_fields: field names matching the data columns + :type import_fields: list(str) + :param match_configs: active configurations for the current model + :type match_configs: recordset of ``base_import.match`` + :returns: (data, import_fields) with ``.id`` prepended when applicable + :rtype: (list(list(str)), list(str)) + """ + Model = self.env[self.res_model] + new_data = [] + any_match = False + skipped = 0 + + for row in data: + row_values = dict(zip(import_fields, row, strict=False)) + db_id = self._find_matching_record(Model, row_values, match_configs) + if db_id is None: + # Ambiguous — skip row entirely (no create, no update). + skipped += 1 + continue + if db_id: + any_match = True + new_data.append([str(db_id) if db_id else ""] + list(row)) + + if not any_match and not skipped: + return data, import_fields + + if not any_match: + # Some rows were skipped (ambiguous); the rest are creates. + # Strip the prepended empty .id column before returning. + return [row[1:] for row in new_data], import_fields + + _logger.info( + "base_import_match: model %s — %d row(s) matched," + " %d skipped as ambiguous.", + self.res_model, + sum(1 for row in new_data if row[0]), + skipped, + ) + return new_data, [".id"] + list(import_fields) + + def _find_matching_record(self, Model, row_values, match_configs): + """Return the database ID of the first unambiguous match, or False. + + Configurations are evaluated in *sequence* order. For each one, + **all** configured fields must be present and non-empty in the row + (AND condition). The search is skipped when any field value cannot + be resolved (e.g. a many2one name that does not exist in the DB). + + :param Model: recordset of the target model (may be empty) + :type Model: odoo.models.BaseModel + :param row_values: mapping of ``{field_name: raw_string_value}`` + :type row_values: dict + :param match_configs: active configurations ordered by sequence + :type match_configs: recordset of ``base_import.match`` + :returns: database ID when exactly one record matches, + ``False`` when no record matches (create new), + ``None`` when multiple records match (skip row) + :rtype: int or False or None + """ + ambiguous = False + for config in match_configs: + if not config.field_ids: + continue + + domain = [] + skip = False + + for field in config.field_ids: + raw_value = row_values.get(field.name) + + if raw_value is None: + # Field not present in CSV — cannot determine match value. + skip = True + break + + if raw_value == "": + # Field present but empty — match records where it is also empty. + domain.append((field.name, "=", False)) + continue + + resolved, ok = self._resolve_field_value(field, raw_value) + if not ok: + skip = True + break + + domain.append((field.name, "=", resolved)) + + if skip or not domain: + continue + + try: + records = Model.search(domain, limit=2) + except Exception: + _logger.exception( + "base_import_match: Error searching with domain %s" " on model %s.", + domain, + Model._name, + ) + continue + + if len(records) == 1: + _logger.debug( + "base_import_match: match found — model: %s," + " config: '%s', record id: %d.", + Model._name, + config.name, + records.id, + ) + return records.id + + if len(records) > 1: + ambiguous = True + _logger.warning( + "base_import_match: config '%s' returned %d records" + " on model %s for domain %s — row skipped.", + config.name, + len(records), + Model._name, + domain, + ) + + return None if ambiguous else False + + def _resolve_field_value(self, field, raw_value): + """Convert a raw CSV string to a value usable in a search domain. + + For **many2one** fields the display name is resolved to a database + ID via :meth:`~odoo.models.BaseModel.name_search`. All other stored + field types are returned as-is. + + :param field: field descriptor from the match configuration + :type field: ``ir.model.fields`` record + :param raw_value: raw string value as read from the import file + :type raw_value: str + :returns: ``(resolved_value, True)`` on success, + ``(None, False)`` when resolution fails + :rtype: tuple(value, bool) + """ + if field.ttype != "many2one": + return raw_value, True + + RelModel = self.env[field.relation] + try: + results = RelModel.name_search(raw_value, operator="=", limit=2) + except Exception: + _logger.exception( + "base_import_match: name_search failed on model %s" " with value '%s'.", + field.relation, + raw_value, + ) + return None, False + + if len(results) == 1: + return results[0][0], True + + if not results: + _logger.debug( + "base_import_match: no record found in '%s'" " with display name '%s'.", + field.relation, + raw_value, + ) + else: + _logger.warning( + "base_import_match: ambiguous many2one — '%s' matches" + " %d records in '%s'.", + raw_value, + len(results), + field.relation, + ) + + return None, False