Repository: OCA/product-attribute
Pull request #1912: [18.0][IMP] product_customerinfo: Allow searching product templates by customer info
Author: login: carlos-lopez-tecnativa
Labels: name: approved
name: merged 🎉
Created: 2025-03-10T15:13:15Z | Merged: 2025-03-21T17:01:06Z

Now, in the sale order, the product template is displayed by default. This update adds the ability to search for product templates using customer info.

Reference: https://github.com/odoo/odoo/pull/155449

TT54233
@Tecnativa @pedrobaeza @victoralmau could you please review this.


Diff:
diff --git a/product_customerinfo/models/product_customerinfo.py b/product_customerinfo/models/product_customerinfo.py
index e9c6252bfe4..3e2c0eb4ded 100644
--- a/product_customerinfo/models/product_customerinfo.py
+++ b/product_customerinfo/models/product_customerinfo.py
@@ -22,3 +22,12 @@ def get_import_templates(self):
                 "product_customerinfo.xls",
             }
         ]
+
+    @api.model
+    def _get_name_search_domain(self, partner_id, operator, name):
+        return [
+            ("partner_id", "=", partner_id),
+            "|",
+            ("product_code", operator, name),
+            ("product_name", operator, name),
+        ]
diff --git a/product_customerinfo/models/product_product.py b/product_customerinfo/models/product_product.py
index 496f7c7d661..a41e03aae93 100644
--- a/product_customerinfo/models/product_product.py
+++ b/product_customerinfo/models/product_product.py
@@ -28,13 +28,10 @@ def name_search(self, name, args=None, operator="ilike", limit=100):
         ):
             return res
         limit -= res_ids_len
-        supplier_domain = [
-            ("partner_id", "=", self._context.get("partner_id")),
-            "|",
-            ("product_code", operator, name),
-            ("product_name", operator, name),
-        ]
-        match_domain = [("product_tmpl_id.customer_ids", "any", supplier_domain)]
+        customer_domain = self.env["product.customerinfo"]._get_name_search_domain(
+            self._context.get("partner_id"), operator, name
+        )
+        match_domain = [("product_tmpl_id.customer_ids", "any", customer_domain)]
         products = self.search_fetch(
             expression.AND([args or [], match_domain]), ["display_name"], limit=limit
         )
diff --git a/product_customerinfo/models/product_template.py b/product_customerinfo/models/product_template.py
index d985ceaeed2..aa8f39a8610 100644
--- a/product_customerinfo/models/product_template.py
+++ b/product_customerinfo/models/product_template.py
@@ -3,7 +3,9 @@
 # Copyright 2015 Tecnativa
 # Copyright 2019 Tecnativa - Pedro M. Baeza
 # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
-from odoo import fields, models
+from odoo import api, fields, models
+from odoo.osv import expression
+from odoo.tools.misc import unique
 
 
 class ProductTemplate(models.Model):
@@ -20,3 +22,86 @@ class ProductTemplate(models.Model):
         inverse_name="product_tmpl_id",
         string="Variant Customer",
     )
+
+    @api.depends_context("display_default_code", "company_id", "partner_id")
+    def _compute_display_name(self):
+        def get_display_name(name, code):
+            if self._context.get("display_default_code", True) and code:
+                return f"[{code}] {name}"
+            return name
+
+        super()._compute_display_name()
+        partner_id = self._context.get("partner_id")
+        if not partner_id:
+            return
+        partner_ids = [
+            partner_id,
+            self.env["res.partner"].browse(partner_id).commercial_partner_id.id,
+        ]
+        company_id = self.env.context.get("company_id")
+
+        # all user don't have access to seller and partner
+        # check access and use superuser
+        self.check_access("read")
+
+        product_template_ids = self.sudo().ids
+        supplier_info_by_template = {}
+        if partner_ids:
+            # prefetch the fields used by the `display_name`
+            domain = [
+                ("product_tmpl_id", "in", product_template_ids),
+                ("product_id", "=", False),
+                ("partner_id", "in", partner_ids),
+            ]
+            if company_id:
+                domain.append(("company_id", "in", [company_id, False]))
+            supplier_info = (
+                self.env["product.customerinfo"]
+                .sudo()
+                .search_fetch(
+                    domain,
+                    ["product_tmpl_id", "company_id", "product_name", "product_code"],
+                )
+            )
+            for r in supplier_info:
+                supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r)
+
+        for product_template in self.sudo():
+            name = product_template.name
+            product_supplier_info = (
+                supplier_info_by_template.get(product_template) or []
+            )
+            if product_supplier_info:
+                temp = []
+                for s in product_supplier_info:
+                    temp.append(
+                        get_display_name(
+                            s.product_name or name,
+                            s.product_code or product_template.default_code,
+                        )
+                    )
+                # => Feature drop here,
+                # one record can only have one display_name now,
+                # instead separate with `,`
+                product_template.display_name = ", ".join(unique(temp))
+
+    @api.model
+    def name_search(self, name, args=None, operator="ilike", limit=100):
+        res = super().name_search(name, args=args, operator=operator, limit=limit)
+        res_ids_len = len(res)
+        if (
+            not name
+            and limit
+            or not self._context.get("partner_id")
+            or res_ids_len >= limit
+        ):
+            return res
+        limit -= res_ids_len
+        customer_domain = self.env["product.customerinfo"]._get_name_search_domain(
+            self._context.get("partner_id"), operator, name
+        )
+        match_domain = [("customer_ids", "any", customer_domain)]
+        products = self.search_fetch(
+            expression.AND([args or [], match_domain]), ["display_name"], limit=limit
+        )
+        return res + [(product.id, product.display_name) for product in products.sudo()]
diff --git a/product_customerinfo/tests/test_product_name_search.py b/product_customerinfo/tests/test_product_name_search.py
index 9d239cd729f..924d7ce3f57 100644
--- a/product_customerinfo/tests/test_product_name_search.py
+++ b/product_customerinfo/tests/test_product_name_search.py
@@ -43,6 +43,14 @@ def test_10_find_product_customer_code(self):
         self.assertEqual(len(product_names), 1)
         self.assertEqual(self.product.id, product_names[0][0])
         self.assertEqual("[code_test] Name_test", product_names[0][1])
+        # search by product template
+        product_template = self.product.product_tmpl_id.with_context(
+            partner_id=self.customer.id
+        )
+        product_names = product_template.name_search(name="code_test")
+        self.assertEqual(len(product_names), 1)
+        self.assertEqual(product_template.id, product_names[0][0])
+        self.assertEqual("[code_test] Name_test", product_names[0][1])
 
         # Search by product default code with the customer used in
         # configuration customer
diff --git a/product_customerinfo/views/product_views.xml b/product_customerinfo/views/product_views.xml
index 08e583743de..eae4ccfb464 100644
--- a/product_customerinfo/views/product_views.xml
+++ b/product_customerinfo/views/product_views.xml
@@ -16,6 +16,7 @@
                             name="product_id"
                             groups="product.group_product_variant"
                             options="{'no_create_edit': True}"
+                            invisible="context.get('product_template_invisible_variant', False)"
                         />
                         <field
                             name="partner_id"
@@ -76,14 +77,16 @@
                 <field name="partner_id" string="Customer" />
                 <field
                     name="product_id"
-                    invisible="context.get('product_template_invisible_variant', False)"
+                    column_invisible="context.get('product_template_invisible_variant', False)"
                     groups="product.group_product_variant"
                 />
+                <!-- Keep this field as readonly to prevent mistakes in product.product.
+                When a user adds more than one customer info, they aren't saved correctly. -->
                 <field
                     name="product_tmpl_id"
+                    readonly="1"
                     string="Product"
-                    invisible="context.get('visible_product_tmpl_id', True)"
-                    groups="base.group_multi_company"
+                    column_invisible="context.get('visible_product_tmpl_id', True)"
                 />
                 <field name="product_name" string="Customer Product Name" />
                 <field name="product_code" string="Customer Product Code" />
@@ -114,6 +117,7 @@
                            'product_template_invisible_variant': True,
                        }"
                     invisible="product_variant_count &gt; 1"
+                    readonly="product_variant_count &gt; 1"
                 />
                 <field
                     name="variant_customer_ids"
@@ -122,10 +126,28 @@
                             'default_product_tmpl_id': context.get('product_tmpl_id',id),
                         }"
                     invisible="product_variant_count &lt;= 1"
+                    readonly="product_variant_count &lt;= 1"
                 />
             </xpath>
         </field>
     </record>
+    <record id="product_product_customer_info_form_view" model="ir.ui.view">
+        <field name="name">product.product.form</field>
+        <field name="model">product.product</field>
+        <field name="inherit_id" ref="product.product_normal_form_view" />
+        <field name="arch" type="xml">
+            <field name="customer_ids" position="attributes">
+                <attribute
+                    name="context"
+                >{'default_product_tmpl_id': product_tmpl_id, 'product_template_invisible_variant': True}</attribute>
+            </field>
+            <field name="variant_customer_ids" position="attributes">
+                <attribute
+                    name="context"
+                >{'default_product_tmpl_id': product_tmpl_id}</attribute>
+            </field>
+        </field>
+    </record>
     <record id="product_customerinfo_search_view" model="ir.ui.view">
         <field name="name">product.customerinfo.search.view</field>
         <field name="model">product.customerinfo</field>


Reviews:
user: login: pedrobaeza
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:17:05Z
user: login: carlos-lopez-tecnativa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T15:42:06Z
user: login: pedrobaeza
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:51:32Z
user: login: pedrobaeza
body: Can part of this code reused in product.product for not duplicating it?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T15:52:21Z
user: login: pedrobaeza
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T18:21:02Z
user: login: victoralmau
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-17T12:32:30Z
user: login: carlos-lopez-tecnativa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T17:22:12Z
user: login: victoralmau
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T16:26:49Z

Review comments:
diff_hunk: @@ -20,3 +22,89 @@ class ProductTemplate(models.Model):
         inverse_name="product_tmpl_id",
         string="Variant Customer",
     )
+
+    @api.depends_context("display_default_code", "company_id", "partner_id")
+    def _compute_display_name(self):
+        def get_display_name(name, code):
+            if self._context.get("display_default_code", True) and code:
+                return f"[{code}] {name}"
+            return name
+
+        super()._compute_display_name()
+        partner_id = self._context.get("partner_id")
+        if not partner_id:
+            return
+        partner_ids = [
+            partner_id,
+            self.env["res.partner"].browse(partner_id).commercial_partner_id.id,
+        ]
+        company_id = self.env.context.get("company_id")
+
+        # all user don't have access to seller and partner
+        # check access and use superuser
+        self.check_access("read")
+
+        product_template_ids = self.sudo().ids
+        supplier_info_by_template = {}
+        if partner_ids:
+            # prefetch the fields used by the `display_name`
+            domain = [
+                ("product_tmpl_id", "in", product_template_ids),
+                ("product_id", "=", False),
+                ("partner_id", "in", partner_ids),
+            ]
+            if company_id:
+                domain.append(("company_id", "in", [company_id, False]))
+            supplier_info = (
+                self.env["product.customerinfo"]
+                .sudo()
+                .search_fetch(
+                    domain,
+                    ["product_tmpl_id", "company_id", "product_name", "product_code"],
+                )
+            )
+            for r in supplier_info:
+                supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r)
+
+        for product_template in self.sudo():
+            name = product_template.name
+            product_supplier_info = (
+                supplier_info_by_template.get(product_template) or []
+            )
+            if product_supplier_info:
+                temp = []
+                for s in product_supplier_info:
+                    temp.append(
+                        get_display_name(
+                            s.product_name or name,
+                            s.product_code or product_template.default_code,
+                        )
+                    )
+                # => Feature drop here,
+                # one record can only have one display_name now,
+                # instead separate with `,`
+                product_template.display_name = ", ".join(unique(temp))
+
+    @api.model
+    def name_search(self, name, args=None, operator="ilike", limit=100):
+        res = super().name_search(name, args=args, operator=operator, limit=limit)
path: product_customerinfo/models/product_template.py
user: login: pedrobaeza
body: I think you should control the extra search by a context key activated in the sales order, and give priority to the result of product.customerinfo records. How is this handled in purchase by core?
created_at: 2025-03-10T15:17:05Z
author_association: MEMBER
diff_hunk: @@ -20,3 +22,89 @@ class ProductTemplate(models.Model):
         inverse_name="product_tmpl_id",
         string="Variant Customer",
     )
+
+    @api.depends_context("display_default_code", "company_id", "partner_id")
+    def _compute_display_name(self):
+        def get_display_name(name, code):
+            if self._context.get("display_default_code", True) and code:
+                return f"[{code}] {name}"
+            return name
+
+        super()._compute_display_name()
+        partner_id = self._context.get("partner_id")
+        if not partner_id:
+            return
+        partner_ids = [
+            partner_id,
+            self.env["res.partner"].browse(partner_id).commercial_partner_id.id,
+        ]
+        company_id = self.env.context.get("company_id")
+
+        # all user don't have access to seller and partner
+        # check access and use superuser
+        self.check_access("read")
+
+        product_template_ids = self.sudo().ids
+        supplier_info_by_template = {}
+        if partner_ids:
+            # prefetch the fields used by the `display_name`
+            domain = [
+                ("product_tmpl_id", "in", product_template_ids),
+                ("product_id", "=", False),
+                ("partner_id", "in", partner_ids),
+            ]
+            if company_id:
+                domain.append(("company_id", "in", [company_id, False]))
+            supplier_info = (
+                self.env["product.customerinfo"]
+                .sudo()
+                .search_fetch(
+                    domain,
+                    ["product_tmpl_id", "company_id", "product_name", "product_code"],
+                )
+            )
+            for r in supplier_info:
+                supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r)
+
+        for product_template in self.sudo():
+            name = product_template.name
+            product_supplier_info = (
+                supplier_info_by_template.get(product_template) or []
+            )
+            if product_supplier_info:
+                temp = []
+                for s in product_supplier_info:
+                    temp.append(
+                        get_display_name(
+                            s.product_name or name,
+                            s.product_code or product_template.default_code,
+                        )
+                    )
+                # => Feature drop here,
+                # one record can only have one display_name now,
+                # instead separate with `,`
+                product_template.display_name = ", ".join(unique(temp))
+
+    @api.model
+    def name_search(self, name, args=None, operator="ilike", limit=100):
+        res = super().name_search(name, args=args, operator=operator, limit=limit)
path: product_customerinfo/models/product_template.py
user: login: carlos-lopez-tecnativa
body: In [purchase.order](https://github.com/odoo/odoo/blob/18edce4d859935bd1425144e4c835acabc5f68f4/addons/purchase/views/purchase_views.xml#L246), the `partner_id` is passed by context, similar to how it is done in [sale.order](https://github.com/odoo/odoo/blob/18edce4d859935bd1425144e4c835acabc5f68f4/addons/sale/views/sale_order_views.xml#L531-L551).

In this module, when searching for `product.product`, the `name_search` does not expect any specific context(just `partner_id`). For `product.template`, it follows the same logic as `product.product`. What exactly needs to be changed? Please provide more details.

`product.product`
https://github.com/OCA/product-attribute/blob/0f082a39b5ea459851acb370f67c05333f557479/product_customerinfo/models/product_product.py#L20-L41

`product.template`
https://github.com/Tecnativa/product-attribute/blob/0b2668128d84949bff76d553e0f4d93fb7e8dfb4/product_customerinfo/models/product_template.py#L95-L96
created_at: 2025-03-10T15:42:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,3 +22,89 @@ class ProductTemplate(models.Model):
         inverse_name="product_tmpl_id",
         string="Variant Customer",
     )
+
+    @api.depends_context("display_default_code", "company_id", "partner_id")
+    def _compute_display_name(self):
+        def get_display_name(name, code):
+            if self._context.get("display_default_code", True) and code:
+                return f"[{code}] {name}"
+            return name
+
+        super()._compute_display_name()
+        partner_id = self._context.get("partner_id")
+        if not partner_id:
+            return
+        partner_ids = [
+            partner_id,
+            self.env["res.partner"].browse(partner_id).commercial_partner_id.id,
+        ]
+        company_id = self.env.context.get("company_id")
+
+        # all user don't have access to seller and partner
+        # check access and use superuser
+        self.check_access("read")
+
+        product_template_ids = self.sudo().ids
+        supplier_info_by_template = {}
+        if partner_ids:
+            # prefetch the fields used by the `display_name`
+            domain = [
+                ("product_tmpl_id", "in", product_template_ids),
+                ("product_id", "=", False),
+                ("partner_id", "in", partner_ids),
+            ]
+            if company_id:
+                domain.append(("company_id", "in", [company_id, False]))
+            supplier_info = (
+                self.env["product.customerinfo"]
+                .sudo()
+                .search_fetch(
+                    domain,
+                    ["product_tmpl_id", "company_id", "product_name", "product_code"],
+                )
+            )
+            for r in supplier_info:
+                supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r)
+
+        for product_template in self.sudo():
+            name = product_template.name
+            product_supplier_info = (
+                supplier_info_by_template.get(product_template) or []
+            )
+            if product_supplier_info:
+                temp = []
+                for s in product_supplier_info:
+                    temp.append(
+                        get_display_name(
+                            s.product_name or name,
+                            s.product_code or product_template.default_code,
+                        )
+                    )
+                # => Feature drop here,
+                # one record can only have one display_name now,
+                # instead separate with `,`
+                product_template.display_name = ", ".join(unique(temp))
+
+    @api.model
+    def name_search(self, name, args=None, operator="ilike", limit=100):
+        res = super().name_search(name, args=args, operator=operator, limit=limit)
path: product_customerinfo/models/product_template.py
user: login: pedrobaeza
body: OK, I see the context key. It seems enough.
created_at: 2025-03-10T15:51:32Z
author_association: MEMBER
diff_hunk: @@ -20,3 +22,86 @@ class ProductTemplate(models.Model):
         inverse_name="product_tmpl_id",
         string="Variant Customer",
     )
+
+    @api.depends_context("display_default_code", "company_id", "partner_id")
+    def _compute_display_name(self):
+        def get_display_name(name, code):
+            if self._context.get("display_default_code", True) and code:
+                return f"[{code}] {name}"
+            return name
+
+        super()._compute_display_name()
+        partner_id = self._context.get("partner_id")
+        if not partner_id:
+            return
+        partner_ids = [
+            partner_id,
+            self.env["res.partner"].browse(partner_id).commercial_partner_id.id,
+        ]
+        company_id = self.env.context.get("company_id")
+
+        # all user don't have access to seller and partner
+        # check access and use superuser
+        self.check_access("read")
+
+        product_template_ids = self.sudo().ids
+        supplier_info_by_template = {}
+        if partner_ids:
+            # prefetch the fields used by the `display_name`
+            domain = [
+                ("product_tmpl_id", "in", product_template_ids),
+                ("product_id", "=", False),
path: product_customerinfo/models/product_template.py
user: login: victoralmau
body: I think this should not be product_id=false or if you define a specific record for a variant it does not work correctly, right? 
created_at: 2025-03-17T12:32:25Z
author_association: MEMBER
diff_hunk: @@ -20,3 +22,86 @@ class ProductTemplate(models.Model):
         inverse_name="product_tmpl_id",
         string="Variant Customer",
     )
+
+    @api.depends_context("display_default_code", "company_id", "partner_id")
+    def _compute_display_name(self):
+        def get_display_name(name, code):
+            if self._context.get("display_default_code", True) and code:
+                return f"[{code}] {name}"
+            return name
+
+        super()._compute_display_name()
+        partner_id = self._context.get("partner_id")
+        if not partner_id:
+            return
+        partner_ids = [
+            partner_id,
+            self.env["res.partner"].browse(partner_id).commercial_partner_id.id,
+        ]
+        company_id = self.env.context.get("company_id")
+
+        # all user don't have access to seller and partner
+        # check access and use superuser
+        self.check_access("read")
+
+        product_template_ids = self.sudo().ids
+        supplier_info_by_template = {}
+        if partner_ids:
+            # prefetch the fields used by the `display_name`
+            domain = [
+                ("product_tmpl_id", "in", product_template_ids),
+                ("product_id", "=", False),
path: product_customerinfo/models/product_template.py
user: login: carlos-lopez-tecnativa
body: When creating a record from `product.product` or `product.template`, the `product_id` field is always empty by default. It is only set when the user explicitly selects a variant in this field. In such cases, the customer info should be linked specifically to that variant. Therefore, from my perspective, it is correct that this record is not considered here.
created_at: 2025-03-17T17:22:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: OCA-git-bot
created_at: 2025-03-10T15:13:19Z
body: Hi @luisg123v,
some modules you are maintaining are being modified, check this out!
author_association: CONTRIBUTOR
user: login: carlos-lopez-tecnativa
created_at: 2025-03-10T17:52:19Z
body: > Can part of this code reused in product.product for not duplicating it?

Note that both `name_search` implementations are different in terms of the fields used: `product.product` relies on `product_tmpl_id.customer_ids`, while `product.template` uses `customer_ids`. I don’t believe it is necessary to create a new `AbstractModel` here. Please let me know if this solution is sufficient or if the abstract model is needed
author_association: CONTRIBUTOR
user: login: pedrobaeza
created_at: 2025-03-21T16:57:27Z
body: /ocabot merge minor
author_association: MEMBER
user: login: OCA-git-bot
created_at: 2025-03-21T16:57:35Z
body: What a great day to merge this nice PR. Let's do it!
Prepared branch [18.0-ocabot-merge-pr-1912-by-pedrobaeza-bump-minor](https://github.com/OCA/product-attribute/commits/18.0-ocabot-merge-pr-1912-by-pedrobaeza-bump-minor), awaiting test results.
author_association: CONTRIBUTOR
user: login: OCA-git-bot
created_at: 2025-03-21T17:01:07Z
body: Congratulations, your PR was merged at 3951d49fddc3c9ac28ba6d7698d7a7653753ebf6. Thanks a lot for contributing to OCA. ❤️
author_association: CONTRIBUTOR